导读:很多同学第一次写 AI Agent,最容易写成一个大
while:调模型、解析tool call、执行工具、把结果塞回上下文,然后继续调。demo 能跑,但一加 UI、一加流式、一加人工审批,马上就开始糊。小编这次看/source/agent这套设计,最大的感受是:Agent 的本质不是模型循环,而是会话状态机。
很多人写 Agent,第一版代码大概都是这样:
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 这套代码,核心就是围绕这个问题做拆分。

一、先别急着写循环,先问谁管会话
小编看这套设计,第一眼最喜欢的是它没有把所有东西塞进 AgentLoop。
它先把两个角色拆开:
| 类 | 职责 | 小编的理解 |
|---|---|---|
AgentRuntime | 接收 UI / CLI / API 命令,维护 session 生命周期 | 会话级调度器 |
AgentLoop | 推进一轮模型调用、处理 tool call、写回消息 | 单轮执行器 |

代码里的注释其实已经把边界说得很清楚:
// AgentLoop = 单轮执行器
// AgentRuntime = 会话级调度器 / command facade
export class AgentRuntime {
private readonly loop: AgentLoop;
}
这句话看起来简单,但它解决的是 Agent 工程里最容易糊的一件事:
UI 不应该直接碰 AgentLoop,AgentLoop 也不应该关心 UI 发来的是什么按钮。
上层只发 CoreCommand:
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 用了两个东西来兜住这个问题:
private readonly sessionAdvanceTails = new Map<string, Promise<void>>();
private readonly sessionAdvanceCounts = new Map<string, number>();
再配合 runSessionAdvance:
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 的情况。
那用户中途发来的消息怎么办?
看这个判断:
private shouldQueueUserInput(sessionId: string, snapshot: SessionSnapshot) {
return (
this.hasPendingAdvance(sessionId) ||
snapshot.status === 'waiting_for_approval' ||
snapshot.pendingToolCall !== null ||
snapshot.pendingToolQueue.length > 0
);
}
说白了,只要当前会话还在推进、等审批、挂着工具、或者还有 tool queue,就不要硬插消息。
而是进入队列:
await this.store.enqueueUserInput(sessionId, queuedInput);
这个地方很像业务系统里的订单状态。
用户点了一下按钮,不代表你马上就能处理。系统要先看当前状态能不能接,如果不能,就排队等下一次安全点。
三、AgentLoop:只管一轮怎么跑,不管 UI 从哪来
AgentLoop 的边界也很干净。
它关心的是一轮 Agent 怎么推进:
- 从
SessionSnapshot读 messages; - 组装
ModelRequest; - 调模型,支持流式和非流式;
- 把完整 assistant message 写回 snapshot;
- 如果有 tool call,就一个个处理;
- 如果没 tool call,就结束 turn,写 telemetry。
代码里这段 run 就是主入口:
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 转成事件:
if (chunk.type === 'text-delta') {
this.bus.publish({
type: 'assistant_text_delta',
sessionId,
turnId: buffer.turnId,
text: chunk.text,
});
}
但最终写入 snapshot.messages 的,还是模型 done 之后的完整 ModelResponse:
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、删除文件,这些动作不可能全自动放行。
这套设计把工具权限拆成三种结果:
export type ToolPermissionDecision =
| { type: 'allow' }
| { type: 'deny'; reason: string }
| { type: 'ask-user'; title: string; message: string };
AgentLoop.processToolCall 遇到 ask-user 时,不是阻塞在那里傻等,而是把当前会话写成一个可恢复状态:
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 再接手:
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 才是这套 Agent 的状态中心:
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 又把这些东西拆成了几个文件:
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 设计,会先问三个问题:
-
谁接收外部命令? 这里是
AgentRuntime.dispatch。 -
谁推进单轮模型和工具? 这里是
AgentLoop.run。 -
谁记录会话状态,保证暂停后还能恢复? 这里是
SessionSnapshot + SessionStore。
这三个问题回答清楚,Agent 就不会轻易糊成一锅。
当然,这套代码还可以继续演进,比如:
createId()现在是Math.random(),生产环境可以换成更稳定的 ID 方案;JsonlSessionStore适合本地 CLI,后面如果要多实例并发,可以换成数据库;AgentRuntime现在是单进程内串行队列,跨进程部署时还需要分布式锁或任务队列;failed状态和异常恢复也可以再补完整。
但这些都不是推翻重写,而是在现有边界上继续升级。
这就是小编喜欢这套设计的原因。
它没有一上来搞很重的框架,也没有把 Agent 写成玄学。
它只是把一个朴素问题讲清楚了:
Agent 能不能跑远,不取决于 while 循环写得多漂亮,而取决于会话状态有没有人管。
模型负责生成,工具负责执行,UI 负责展示。
但中间那本账,必须有人认真记。