返回博客列表

AI Agent 跑不远,问题往往出在会话状态

2026-07-04
4 min read
aiagent

小编看 jvs-cli 这套 Agent 设计,最值得讲的不是怎么调模型,而是它把会话状态、单轮执行、UI 事件和工具审批拆开了。Agent 真正跑得远,靠的不是一个大 while,而是谁来管状态。

导读:很多同学第一次写 AI Agent,最容易写成一个大 while:调模型、解析 tool call、执行工具、把结果塞回上下文,然后继续调。demo 能跑,但一加 UI、一加流式、一加人工审批,马上就开始糊。小编这次看 /source/agent 这套设计,最大的感受是:Agent 的本质不是模型循环,而是会话状态机。

很多人写 Agent,第一版代码大概都是这样:

ts
while (true) {
  const response = await model(messages);
  const toolCalls = parseToolCalls(response);
  const results = await runTools(toolCalls);
  messages.push(response, ...results);
}

小编一开始也觉得,这不挺顺吗?

模型要工具,就执行工具;工具执行完,再塞回去继续问模型。逻辑清楚,demo 也能跑。

但真到业务里,麻烦马上来了:

  • CLI 要换成 Web,UI 和 Agent 逻辑怎么分开?
  • 模型流式返回,用户要实时看,最终结果还要稳定落库,谁负责?
  • rm 这种危险工具要人工审批,会话暂停在哪里?
  • 用户在 Agent 正跑的时候又发了一句话,是丢掉还是排队?
  • 一轮 Agent 里可能有多次模型调用,token、耗时、步骤怎么统计?

说白了,真正难的不是把模型调通,而是把会话状态管明白。

jvs-cli/source/agent 这套代码,核心就是围绕这个问题做拆分。

大 while 循环把模型、工具、UI 和状态糊在一起

一、先别急着写循环,先问谁管会话

小编看这套设计,第一眼最喜欢的是它没有把所有东西塞进 AgentLoop

它先把两个角色拆开:

职责小编的理解
AgentRuntime接收 UI / CLI / API 命令,维护 session 生命周期会话级调度器
AgentLoop推进一轮模型调用、处理 tool call、写回消息单轮执行器

AgentRuntime 管命令入口,AgentLoop 管单轮执行

代码里的注释其实已经把边界说得很清楚:

ts
// AgentLoop = 单轮执行器
// AgentRuntime = 会话级调度器 / command facade
export class AgentRuntime {
  private readonly loop: AgentLoop;
}

这句话看起来简单,但它解决的是 Agent 工程里最容易糊的一件事:

UI 不应该直接碰 AgentLoop,AgentLoop 也不应该关心 UI 发来的是什么按钮。

上层只发 CoreCommand

ts
export type CoreCommand =
  | { type: 'start_session'; input: string }
  | { type: 'append_user_message'; sessionId: string; input: string }
  | { type: 'approve_request'; sessionId: string; requestId: string }
  | { type: 'reject_request'; sessionId: string; requestId: string }
  | { type: 'load_session'; sessionId: string };

这个设计的好处是,未来你把 CLI 换成 Web、把 Web 换成桌面端,底层 Agent 不需要跟着 UI 重写。

UI 只管发命令,Runtime 决定当前会话应该怎么走。

小编更愿意把它理解成一层“控制面”:它不亲自跑每一步,但它决定这场会话什么时候开始、什么时候暂停、什么时候恢复、什么时候排队。

二、AgentRuntime:不要让并发消息把会话打乱

Agent 最烦人的场景之一,是用户在它还没干完活时,又发了一句话。

这个时候不能简单粗暴地把消息丢进 messages

因为当前 turn 可能还在流式输出,可能正在执行工具,也可能停在人工审批上。你硬塞进去,模型上下文就乱了。

这套设计里,AgentRuntime 用了两个东西来兜住这个问题:

ts
private readonly sessionAdvanceTails = new Map<string, Promise<void>>();
private readonly sessionAdvanceCounts = new Map<string, number>();

再配合 runSessionAdvance

ts
private async runSessionAdvance(sessionId: string, task: () => Promise<void>) {
  const previous = this.sessionAdvanceTails.get(sessionId) ?? Promise.resolve();
  const current = previous.then(task);
  this.sessionAdvanceTails.set(sessionId, current.catch(() => {}));

  await current;
}

小编觉得这里很关键。

它没有搞一个全局大锁,而是按 sessionId 串行推进。也就是说:

  • A 会话和 B 会话可以互不影响;
  • 同一个会话里的命令必须排队执行;
  • 不会出现两个 turn 同时改同一份 snapshot 的情况。

那用户中途发来的消息怎么办?

看这个判断:

ts
private shouldQueueUserInput(sessionId: string, snapshot: SessionSnapshot) {
  return (
    this.hasPendingAdvance(sessionId) ||
    snapshot.status === 'waiting_for_approval' ||
    snapshot.pendingToolCall !== null ||
    snapshot.pendingToolQueue.length > 0
  );
}

说白了,只要当前会话还在推进、等审批、挂着工具、或者还有 tool queue,就不要硬插消息。

而是进入队列:

ts
await this.store.enqueueUserInput(sessionId, queuedInput);

这个地方很像业务系统里的订单状态。

用户点了一下按钮,不代表你马上就能处理。系统要先看当前状态能不能接,如果不能,就排队等下一次安全点。

三、AgentLoop:只管一轮怎么跑,不管 UI 从哪来

AgentLoop 的边界也很干净。

它关心的是一轮 Agent 怎么推进:

  1. SessionSnapshot 读 messages;
  2. 组装 ModelRequest
  3. 调模型,支持流式和非流式;
  4. 把完整 assistant message 写回 snapshot;
  5. 如果有 tool call,就一个个处理;
  6. 如果没 tool call,就结束 turn,写 telemetry。

代码里这段 run 就是主入口:

ts
async run(sessionId: string, stream = this.options.streamByDefault ?? true): Promise<void> {
  const snapshot = await this.mustLoadSnapshot(sessionId);
  let state = this.activeTurns.get(sessionId);

  if (!state) {
    state = {
      currentTurnId: createId(),
      turnStartTime: Date.now(),
      steps: [],
    };
    this.activeTurns.set(sessionId, state);
  }

  const request = this.buildModelRequest(snapshot);
  const response = stream
    ? await this.runStreamTurn(sessionId, state.currentTurnId, request)
    : await this.runGenerateTurn(sessionId, state.currentTurnId, request);

  await this.applyModelResponse(sessionId, state, response);
}

这里小编想强调一个点:流式输出不等于边流边乱写数据库。

这套代码把“实时展示”和“最终落库”分开了。

流式时,runStreamTurn 只把 chunk 转成事件:

ts
if (chunk.type === 'text-delta') {
  this.bus.publish({
    type: 'assistant_text_delta',
    sessionId,
    turnId: buffer.turnId,
    text: chunk.text,
  });
}

但最终写入 snapshot.messages 的,还是模型 done 之后的完整 ModelResponse

ts
const assistantMessage: Message = {
  role: 'assistant',
  turnId: state.currentTurnId,
  content: response.text,
  toolCalls: response.toolCalls,
};
snapshot.messages.push(assistantMessage);

这个设计很务实。

UI 想实时展示,就订阅 assistant_text_delta

存储想要稳定结果,就等完整 response。

小编以前见过不少 Agent demo,流式 token 一边出来一边写状态,最后一旦中途失败,数据库里就留下一坨半截消息。这个坑真不小。

四、工具审批:暂停不是睡眠,而是状态落库

Agent 一旦能调用工具,就绕不开权限。

比如读文件、执行 shell、删除文件,这些动作不可能全自动放行。

这套设计把工具权限拆成三种结果:

ts
export type ToolPermissionDecision =
  | { type: 'allow' }
  | { type: 'deny'; reason: string }
  | { type: 'ask-user'; title: string; message: string };

AgentLoop.processToolCall 遇到 ask-user 时,不是阻塞在那里傻等,而是把当前会话写成一个可恢复状态:

ts
snapshot.pendingApproval = request;
snapshot.pendingToolCall = toolCall;
snapshot.pendingToolQueue = remainingQueue;
snapshot.status = 'waiting_for_approval';
await this.store.saveSnapshot(snapshot);

小编觉得这里是整套设计最像“生产系统”的地方。

因为人工审批可能等几秒,也可能等几分钟,甚至用户把页面关了再回来。

如果你只是让一个 Promise 挂在那里等,服务一重启就全丢了。

但如果你把状态写进 SessionSnapshot

  • 当前等哪个审批;
  • 被挂起的是哪个 tool call;
  • 后面还剩哪些 tool call;
  • 会话现在是什么状态;

这些东西都在 store 里,恢复就有抓手。

审批通过时,AgentRuntime 再接手:

ts
const result = await this.tools.execute(toolCall, { approved: true });
latestSnapshot.pendingApproval = null;
latestSnapshot.pendingToolCall = null;
const remainingToolQueue = [...latestSnapshot.pendingToolQueue];
latestSnapshot.pendingToolQueue = [];
latestSnapshot.status = 'running';

如果还有剩余工具队列,就继续 resumeWithToolQueue;没有就重新进 loop.run

这就不是简单的“用户点了同意按钮”,而是完整的暂停、持久化、恢复协议。

五、SessionSnapshot:真正的会话账本

这篇文章讲到这里,其实核心已经很清楚了。

SessionSnapshot 像一本会话账本,记录 pending、queue 和 messages

SessionSnapshot 才是这套 Agent 的状态中心:

ts
export type SessionSnapshot = {
  sessionId: string;
  status: SessionStatus;
  messages: Message[];
  queuedUserInputs: QueuedUserInput[];
  pendingApproval: ApprovalRequest | null;
  pendingToolCall: ToolCall | null;
  pendingToolQueue: ToolCall[];
  latestTurnId: string | null;
  updatedAt: string;
  createdAt: string;
};

小编更愿意把它叫“会话账本”。

模型回答只是账本的一部分,工具结果也是账本的一部分,审批状态也是账本的一部分,排队输入也是账本的一部分。

也正因为有这份账本,系统才能回答几个关键问题:

问题靠什么回答
当前会话跑完了吗?status
正在等哪个工具审批?pendingApproval / pendingToolCall
用户中途发的新消息放哪里?queuedUserInputs
当前 turn 是哪一轮?latestTurnId
模型和工具历史怎么回放?messages

JsonlSessionStore 又把这些东西拆成了几个文件:

ts
const SESSION_FILE_NAME = 'session.json';
const MESSAGE_FILE_NAME = 'messages.jsonl';
const QUEUED_INPUT_FILE_NAME = 'queued-user-inputs.jsonl';
const TURN_TELEMETRY_FILE_NAME = 'turn-telemetry.jsonl';
const TURN_STEPS_FILE_NAME = 'turn-steps.jsonl';

这里不是为了炫技。

session.json 放索引状态,messages.jsonl 放消息历史,queued-user-inputs.jsonl 放排队输入,telemetry 单独记录。

这比所有东西塞进一个大 JSON 里更像一个可演进的会话存储。

六、这套设计真正解决了什么

回到开头那个大 while

小编现在再看 Agent 设计,会先问三个问题:

  1. 谁接收外部命令? 这里是 AgentRuntime.dispatch

  2. 谁推进单轮模型和工具? 这里是 AgentLoop.run

  3. 谁记录会话状态,保证暂停后还能恢复? 这里是 SessionSnapshot + SessionStore

这三个问题回答清楚,Agent 就不会轻易糊成一锅。

当然,这套代码还可以继续演进,比如:

  • createId() 现在是 Math.random(),生产环境可以换成更稳定的 ID 方案;
  • JsonlSessionStore 适合本地 CLI,后面如果要多实例并发,可以换成数据库;
  • AgentRuntime 现在是单进程内串行队列,跨进程部署时还需要分布式锁或任务队列;
  • failed 状态和异常恢复也可以再补完整。

但这些都不是推翻重写,而是在现有边界上继续升级。

这就是小编喜欢这套设计的原因。

它没有一上来搞很重的框架,也没有把 Agent 写成玄学。

它只是把一个朴素问题讲清楚了:

Agent 能不能跑远,不取决于 while 循环写得多漂亮,而取决于会话状态有没有人管。

模型负责生成,工具负责执行,UI 负责展示。

但中间那本账,必须有人认真记。

返回博客列表
最后更新于 2026-07-04
想法或问题?在 GitHub Issue 下方参与讨论
去评论