返回博客列表

写一个 Agent CLI 到底难在哪-claude

2026-07-02
5 min read

> [TIP] > 本篇文章通读时间大概 8 分钟。小编最近写了一个跑在终端里的 AI Agent —— financecli, > 专门干财务报销这摊活。但今天不聊报销,聊的是这玩意儿背后那套分层设计。 > 一定要认真看并思考,看完你自己也能撸一个像模像样的 Agent CLI 出来。废话不多说,直接上干货。 一、先聊聊:写一个 Agent CLI 到底难在哪 很多同学一上手写 AI Agent...

[!TIP] 本篇文章通读时间大概 8 分钟。小编最近写了一个跑在终端里的 AI Agent —— finance-cli, 专门干财务报销这摊活。但今天不聊报销,聊的是这玩意儿背后那套分层设计。 一定要认真看并思考,看完你自己也能撸一个像模像样的 Agent CLI 出来。废话不多说,直接上干货。

一、先聊聊:写一个 Agent CLI 到底难在哪

很多同学一上手写 AI Agent,代码是这样的:一个大 while 循环,里面调大模型、解析 tool call、执行工具、再塞回去接着调,UI 和逻辑全糊在一起。

demo 阶段跑得挺爽,可一旦要加功能就抓瞎了:

  • 想把命令行换成一个网页界面,发现 UI 和 Agent 逻辑根本扒不开。
  • 大模型是流式返回的,一个字一个字往外蹦,你怎么既让用户实时看到、又能把最终结果稳稳存下来?
  • 工具要执行 rm 这种危险操作,得让用户点一下「同意」,可这一等可能是好几分钟,会话怎么暂停、又怎么恢复?
  • 用户在 Agent 正吭哧吭哧干活的时候又敲了一句话进来,这消息该丢掉还是排队?

说白了,难的不是调通大模型,而是把「会话状态」这件事管明白。小编这套设计,核心就是围绕「谁来管会话状态」展开的。

二、整体分层:七个目录各司其职

先看目录,一共七层,每层只干一件事:

text
source/
├── config/     读取 llm、api key、session 路径配置
├── protocol/   Agent 和 UI 之间的交互协议(契约层)
├── agent/      session 的唯一 owner,负责推进任务
├── session/    session 的持久化与恢复
├── ui/         纯渲染、纯展示
├── llm/        大模型抽象 + 各家 SDK 的 adapter
└── tool/       工具的权限判断与执行

各层职责,小编列个表你一眼就懂:

干啥不干啥
config解析 provider、modelId、apiKey、session 根目录不碰业务逻辑
protocol定义 Command / Event / Snapshot / Telemetry 这些数据契约没有任何行为,纯类型
agent持有会话生命周期,驱动一轮 turn,调 llm / tool / session不做渲染,不直接读 UI 输入
session快照读写、telemetry 落盘不管任务怎么推进
ui发 command、订阅 event、读 snapshot,展示消息和耗时绝不直接改 session
llm统一 generate() / stream() 接口不关心是哪家模型
tool统一权限判断 + 统一执行 + 工具注册中心不关心谁在调它

这里最值得说道的就一句话,也是这套架构的立身之本:

=Agent 是 session 的唯一 owner,UI 不直接改 session,LLM 的原始流只进 agent。=

三、一条铁律:单向数据流

顺着上面那句话往下捋,整个系统的数据流是单向的,一点都不乱:

翻译成白话就是:

  1. UI 只能发命令(Command),比如「开个新会话」「批准这个工具」。
  2. 命令统一进 AgentRuntime,由它决定要不要推进会话。
  3. Agent 干活过程中产生的一切变化,都以事件(Event)的形式广播出去。
  4. UI 订阅事件,被动地更新界面。

你发现没有?UI 从头到尾没有一行代码去直接读写 session。它想知道现在啥情况,只有一个途径——订阅 snapshot_updated 事件。这就是 README 里写的那句原则:

LLM 原始流只进入 agent,agent 把原始流转成 UI 友好的事件,session 只存稳定结果,不存半截 chunk。

好处是啥?UI 想换就换。 今天是命令行(cli/),明天想搞个网页 app(项目里已经有 prototype/ 在试),底层 Agent 一行都不用动,因为大家都只认协议。

四、AgentRuntime 与 AgentLoop:调度器与执行器分家

agent/ 这一层,小编特意拆成了两个类,这是整篇文章的重点,一定要看明白。

  • AgentLoop = 单轮执行器,只管「一轮 turn 内部怎么跑」。
  • AgentRuntime = 会话级调度器,管「整个会话的生命周期和命令分发」。

为啥要拆?因为「跑一轮模型」和「管一场会话」根本是两件事,揉一起迟早乱套。

4.1 AgentRuntime:会话级的命令门面

AgentRuntime 对外只暴露一个入口 dispatch(command),上层所有操作都是发命令进来:

ts
async dispatch(command: CoreCommand): Promise<void> {
  switch (command.type) {
    case 'start_session':        // 开新会话:建 snapshot、写第一条 user message、跑第一轮
    case 'append_user_message':  // 老会话里继续对话
    case 'approve_request':      // 工具审批通过:执行被挂起的工具
    case 'reject_request':       // 工具审批拒绝:把「用户拒绝」当成 tool error 写回
    case 'load_session':         // 只把当前快照重新广播给 UI,不推进
  }
}

看到没,AgentRuntime 自己不关心大模型一轮怎么跑,它只做会话级别的状态切换,然后在合适的时机把活儿甩给 AgentLoop。一句话概括:

=AgentLoop 是单轮执行器,AgentRuntime 是会话级调度器 / command facade。=

4.2 AgentLoop:一轮 turn 的推进逻辑

AgentLoop.run() 才是真正干活的地方,流程清清楚楚:

ts
async run(sessionId: string, stream = true): Promise<void> {
  const snapshot = await this.mustLoadSnapshot(sessionId); // 1. 读快照
  const turnId = createId();                               // 2. 生成 turnId
  // ...
  this.bus.publish({ type: 'turn_started', sessionId, turnId }); // 3. 广播 turn 开始

  const request = this.buildModelRequest(snapshot.messages);     // 4. 拼模型请求
  const response = stream
    ? await this.runStreamTurn(sessionId, turnId, request)       //    流式
    : await this.runGenerateTurn(sessionId, turnId, request);    //    非流式

  await this.applyModelResponse(sessionId, turnId, response);    // 5. 统一落库 + 继续处理
}

它只关心五件事:读上下文 → 调模型 → 写回 session → 处理 tool calls → 遇到审批就暂停。至于「session 谁创建的」「UI 命令谁接的」,一概不管。职责收得干干净净。

五、Command / Event:两边只靠协议说话

protocol/ 这一层没有任何逻辑,全是类型定义,但它是整个系统的「普通话」。

命令是「我要你做什么」:

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 };

事件是「刚刚发生了什么」:

ts
export type CoreEvent =
  | { type: 'snapshot_updated'; /* 会话快照变了 */ }
  | { type: 'turn_started'; /* 新一轮开始 */ }
  | { type: 'assistant_text_delta'; text: string; /* 助手文本增量 */ }
  | { type: 'tool_call_delta'; preview: string; /* 工具参数增量 */ }
  | { type: 'telemetry_progress'; /* 实时耗时 / token */ }
  | { type: 'approval_requested'; /* 有工具要审批 */ }
  | { type: 'turn_finished'; /* 本轮结束 */ }
  | { type: 'session_completed'; /* 整场会话完事 */ }
  // ...

两边靠一个薄薄的 EventBus 接口连起来,就俩方法:

ts
export interface EventBus {
  publish(event: CoreEvent): void;
  subscribe(listener: (event: CoreEvent) => void): () => void;
}

这其实就是命令查询职责分离(CQRS)那套思想的轻量版:写走 Command,读走 Event,中间用总线彻底解耦。

六、流式怎么处理:半截 chunk 绝不进 session

这是小编觉得最巧的一块,大家仔细看看。

大模型流式返回时,是一个个 chunk 往外蹦的。新手容易犯的错,是把这些原始 chunk 直接怼给 UI、甚至直接往数据库里写。结果就是存了一堆残缺不全的半截数据。

这套设计的做法是——流式过程只广播事件,最终结果另算

ts
private async runStreamTurn(sessionId, turnId, request): Promise<ModelResponse> {
  const buffer = createTurnBuffer(turnId);
  let finalResponse: ModelResponse | null = null;

  for await (const chunk of this.llm.stream(request)) {
    this.consumeStreamChunk(sessionId, buffer, chunk); // 把 chunk 转成 UI 事件广播出去
    if (chunk.type === 'done') {
      finalResponse = chunk.response;                  // 只有 done 那一下,才是最终结果
    }
  }

  if (!finalResponse) throw new Error('流式模型调用没有返回 done 事件');
  return finalResponse; // 拿完整结果去落库
}

consumeStreamChunk 把三类增量翻译成 UI 能懂的事件:text-delta(文本增量)、tool-call-delta(工具参数增量)、usage(token 统计)。这些事件只管「让用户实时看到点动静」,一个字都不会写进最终持久化的消息

done 事件到了,才把完整的 ModelResponse 拼出来,交给 applyModelResponse 统一落库。这样就做到了:

=UI 只需要理解协议事件,不需要理解底层模型 SDK;session 里存的永远是稳定的完整结果。=

七、工具权限三态:allow / deny / ask-user

Agent 最危险的地方就是执行工具,尤其是 shell 这种。所以工具层把权限判断和执行分成了两个方法:

ts
export type ToolPermissionDecision =
  | { type: 'allow' }                                 // 放行,直接执行
  | { type: 'deny'; reason: string }                 // 拒绝,写个 tool error 就过
  | { type: 'ask-user'; title: string; message: string }; // 问用户,得停下来等

export interface ToolExecutor {
  checkPermission(toolCall: ToolCall): ToolPermissionDecision;
  execute(toolCall: ToolCall): Promise<unknown>;
}

重点在 ask-user 这条路——它要求会话中断,然后还能恢复。看 AgentLoop 里怎么处理:

ts
if (permission.type === 'ask-user') {
  snapshot.pendingApproval = request;      // 记下:正在等哪个审批
  snapshot.pendingToolCall = toolCall;     // 记下:等的是哪个工具
  snapshot.pendingToolQueue = remainingQueue; // 记下:这工具后面还排着哪些没跑
  snapshot.status = 'waiting_for_approval';
  await this.store.saveSnapshot(snapshot); // 状态落盘!
  this.bus.publish({ type: 'approval_requested', /* ... */ });
  return true; // 返回 true = 这个 loop 先暂停
}

这里的精髓是:它把「暂停点」完整地记进了快照——等谁、等啥、后面还有啥没干。等用户点了「批准」,AgentRuntime 收到 approve_request,就能精准地从断点续上:

ts
if (remainingToolQueue.length > 0) {
  await this.loop.resumeWithToolQueue(sessionId, remainingToolQueue); // 先把剩下的工具跑完
} else {
  await this.loop.run(sessionId); // 队列空了,进下一轮模型调用
}

模型一次可能吐出好几个 tool call,中间某个要审批,前面批准的不能白跑、后面排队的不能丢——pendingToolQueue + resumeWithToolQueue 这一对,就是专门伺候这个场景的。说白了,这就是给 Agent 加了个「存档/读档」功能。

八、Port / Adapter:把三方 SDK 关进笼子里

你可能注意到了,llmsessiontoolEventBus 全都是接口,不是具体实现。这就是依赖倒置(也叫端口与适配器 / 六边形架构)。

以大模型为例,核心只认这个接口:

ts
export interface ModelProvider {
  generate(request: ModelRequest): Promise<ModelResponse>;
  stream(request: ModelRequest): AsyncIterable<ModelStreamChunk>;
}

至于你用的是 DeepSeek、还是 Vercel AI SDK,那都是 llm/adapters/ 下面的适配器细节。DeepSeekModelProvider 干的活,无非就是把三方返回值「翻译」成自己的 ModelResponse

ts
export class DeepSeekModelProvider implements ModelProvider {
  async generate(request: ModelRequest): Promise<ModelResponse> {
    const result = await generateText({ /* 调 Vercel AI SDK */ });
    return normalizeGenerateResult({ /* 归一化成自己的协议 */ });
  }
}

同理,SessionStore 是接口,今天落地成 JsonlSessionStore(一个 session 一个目录,里面放 session.jsonmessages.jsonlturn-telemetry.jsonl),明天想换成数据库、内存实现,Agent 核心照样一行不改。

这么做的价值,一句话:=把易变的三方 SDK 挡在核心之外,核心只依赖自己定义的稳定接口。=

九、几个容易忽略、但特别关键的细节

这几个点小编单独拎出来讲,因为它们最能体现「有没有认真想过生产环境」。

1. 同一会话必须串行推进。 AgentRuntime 用一条 Promise 尾链,保证同一个 session 的操作一个接一个来,不会并发打架:

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;
}

2. 用户抢话要排队,不能丢。 Agent 正忙或正等审批时用户又发消息,走 queuedUserInputs 排队;等这一轮彻底跑完、没有 tool call 了,再 dequeueUserInput 把排队的消息接上继续跑。

3. Telemetry 分两层。 一层是 TurnTelemetry(单轮的耗时、token、上下文占比),一层是 SessionTelemetryTotals(整场会话的累计)。别搅在一起,UI 上「本轮耗时」和「累计消耗」是两码事。

十、总结:这套设计到底好在哪

啰嗦这么多,小编帮你把这套设计的精髓收一收:

设计点解决的问题
七层分层每层单一职责,改哪层不牵连别层
Agent 独占 session单向数据流,状态永远只有一个源头
Runtime / Loop 拆分会话调度和单轮执行解耦,各自清爽
Command / Event 协议UI 和核心彻底解耦,命令行随时能换成网页
流式与持久化分离用户实时可见,session 只存稳定结果
工具权限三态 + 断点危险操作可审批,会话能暂停能恢复(存档/读档)
Port / Adapter大模型、存储想换就换,核心稳如泰山

你会发现,真正让一个 Agent 从「demo」走向「能用」的,从来不是模型调得多花哨,而是——

=把会话状态这件事,交给一个明确的 owner,用协议和事件把各层解耦,再把每一个「暂停点」都老老实实记进快照。=

好了,本篇就到这。这套骨架不止能写财务 Agent,你换成任何领域的 Agent CLI 都一样能套。希望对你有所帮助,大家可以照着自己撸一个试试。

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