扒开 ZCode 的源码:AI 编程 Agent 到底是怎么"动嘴不动手"帮你干活的?

本文基于开源仓库 zai-org/ZCode v3.15.1(commit aac47556)的真实源码写成。所有代码片段都来自仓库原文,文件路径均为仓库内相对路径,可以自行对照。

前言:你可能天天在用,但从没看过它的发动机

这两年大家多少都用过 AI 编程助手:输入一句"帮我修复登录页的 bug",它就能自己翻代码、跑命令、改文件,最后告诉你"改好了,测试也跑过了"。

看起来很魔法,但如果你问一句"它到底是怎么做到的",很多人(包括不少天天用的人)都说不太清楚。是模型直接改了文件?是有什么黑科技?

答案其实朴素得惊人:核心就是一个 while (true) 循环。

ZCode 是智谱开源的 AI 编程工作台,包含桌面应用、Web 界面和终端 Agent,全仓 TypeScript 代码量在数十万行级别(仓库内 2026-06 的审计文档统计约 63 万行,其中一半以上是 UI)。本文我们聚焦其中最核心的部分------apps/zcode-cli 里的 Agent 本体,把它的实现原理掰开揉碎讲清楚。

读完本文你将搞明白四件事:

  1. AI Agent 的"发动机"------Agent Loop 到底长什么样;
  2. 模型明明只会输出文字,是怎么"执行命令、改文件"的;
  3. 为什么它不会把你的电脑搞炸------权限系统的设计;
  4. 对话越来越长,它是怎么不"失忆"也不"爆内存"的。

别担心,我们用一个餐厅的故事开胃,再逐层下源码。不需要任何 AI 开发经验,会写一点代码就能看懂。


一、开场故事:一家"大厨从不进后厨"的米其林餐厅

先讲个故事。

想象一家神奇的米其林餐厅:

  • 大厨 满腹经纶,尝遍天下名菜,任何菜谱倒背如流。但他有个怪癖------这辈子从没进过后厨,手上连锅铲都没拿过。他只动嘴。
  • 顾客(你)坐下点菜:"给我来一份'修复登录 bug'套餐。"
  • 这时候,领班出现了。他的工作极其机械:把订单递给大厨;大厨动嘴指挥"去看一眼冰箱第三层有什么";领班就跑进后厨,翻出来端回去汇报;大厨继续指挥"把黄油切成 5 毫米片";领班再跑一趟......
  • 后厨里站着一群帮厨:有人专管开火(能执行任何命令,威力大但也危险),有人专管看食材(只读,随便看不会出事),有人专管改菜(改之前得对暗号)。
  • 后厨门口站着一位保安:帮厨每做一个危险动作(比如开火),保安都必须先拦下来,跑到大堂让你签字:"顾客您好,您的菜需要动明火,允许吗?"你点头,他放行;你摇头,帮厨就换一种做法。
  • 大厨一个人忙不过来?没关系,餐厅还有几个徒弟------大厨可以把"去仓库盘点一下库存"这种杂活整个丢给徒弟,徒弟自己有一套小冰箱小工具,干完了把盘点单交回大厨,大厨接着做主菜。
  • 最后大厨说:"菜好了。"领班端出去,顾客吃菜。

现在揭晓答案:

故事角色 ZCode 里的真身 源码位置
顾客 用户(你) ---
订单 Prompt(你输入的那句话) ---
大厨 大语言模型(GLM / Claude / GPT......) 通过 Provider 层接入
领班 Agent 运行时 AgentRuntime apps/zcode-cli/packages/core/src/runtime/
领班的工作循环 Agent Loop(一个 while (true)) packages/core/src/runtime/methods/turn-loop.ts
帮厨团队 工具:Bash、Read、Edit、Grep...... packages/core/src/tool/handlers/
保安 权限系统 PermissionService packages/core/src/permission/service.ts
店规(挂在墙上的操作守则) 系统提示词 packages/core/src/context/builder.ts
徒弟 子代理(subagent) packages/core/src/subagent/runner.ts
点菜单太厚 → 抄个摘要 上下文压缩(compact) packages/core/src/compact/
记账本 SQLite 会话持久化 packages/adapters/src/storage/session-store/

整个 ZCode 的秘密就是:大厨(模型)从头到尾只负责"说",所有"做"都由领班指挥帮厨完成,而领班永不疲倦地在两者之间跑腿。

下面逐层看源码。


二、先看全景:这个仓库长什么样

ZCode 是一个 pnpm monorepo(仓库套仓库),顶层结构如下:

makefile 复制代码
D:\github\ZCode
├── packages/
│   ├── desktop/        # Electron 桌面端(Main / Host / Renderer)
│   ├── web/            # Web 客户端(Vite SPA)
│   ├── server/         # HTTP / WebSocket 服务
│   ├── ui/             # 共享 React 组件库(体积最大的一块)
│   ├── services/       # 业务服务与持久化
│   ├── rpc/            # 传输无关、类型安全的 RPC 框架
│   ├── provider/       # 模型供应商的"配置与目录"层
│   └── shared/         # 共享协议和类型
├── apps/
│   └── zcode-cli/      # ★ Agent CLI、TUI、运行时------本文主角
│       └── packages/
│           ├── core/       # ★ Agent 内核:主循环、工具、权限、上下文
│           ├── cli/        # 命令行入口(参数解析、分流)
│           ├── tui/        # 终端交互界面
│           ├── bootstrap/  # 组装车间:把所有零件拼成一个 App
│           ├── adapters/   # 适配器:真正干脏活(spawn 进程、读文件、连模型)
│           ├── contracts/  # 契约:跨包共享的类型与 Schema
│           └── *-plugin/   # 各种官方插件(浏览器、PDF、表格......)
├── docs/               # 海量设计文档(含一篇项目架构自审报告)
└── scripts/            # 构建与发行脚本

有几个工程上的第一印象值得先说:

  • 端口-适配器架构(六边形架构)贯彻得很彻底 :core 包里的 Agent 内核只依赖 ExecutionPort、FileSystemPort、McpPort 这样的抽象接口,真正 spawn 子进程、读磁盘、发 HTTP 的代码全在 adapters 包。内核是纯逻辑,可以单测、可以换实现。
  • 类型契约先行 :消息、事件、工具 schema 全部集中在 contracts 包,用 TypeScript 的判别联合类型写出来,改协议先改契约。
  • 文档密度惊人 :docs/ 目录下有数百篇设计决策文档,甚至有一篇对自家架构的批判性审计报告(docs/ZCode 项目架构分析与改进建议.md),直接点名自己"CI 测试 job 全部被禁用""botsService 单文件 7015 行"------这种自黑精神相当罕见。

好,进入正题。故事从你在终端敲下 zcode 开始。


三、启动:从敲下 zcode 到"欢迎光临"

3.1 一个入口,三种去法

安装后得到的 zcode 命令有三种玩法:

bash 复制代码
zcode                      # 无参数 → 进入终端交互界面(TUI)
zcode -p "帮我统计代码行数"  # -p → 一次性任务,干完就退出(headless)
zcode --web                # → 启动 Web 服务,浏览器里用

分流逻辑在哪?先看入口。apps/zcode-cli/packages/cli/package.json 声明了 bin:

json 复制代码
"bin": {
  "zcode": "./dist/zcode.cjs"
}

zcode.cjs 是把 apps/zcode-cli/packages/cli/src/main.ts 用 esbuild 打成的单文件。main.ts 开头是一串"开店前的准备":

javascript 复制代码
// apps/zcode-cli/packages/cli/src/main.ts
void main();
​
async function main(): Promise<void> {
  const argv = process.argv.slice(2);
  // 存储模式也可运行在 Host Worker 中,不能修改整个 Host 的进程名称。
  if (!argv.includes("--prepare-storage")) setCliProcessTitle();
  // ......清洗 NODE_ENV / 代理环境变量、安装 stderr 边界
  // 准备模型 Provider 的运行时环境(找配置文件、注入凭证路径)
  const { prepareCliProviderRuntimeEnv } = await import("./provider-runtime-env.js");
  Object.assign(process.env, await prepareCliProviderRuntimeEnv({ argv, env: process.env }));
  // ......
  const { run } = await import("./run.js");
  // ......
  const exitCode = await run(context, { /* ... */ });
}

注意这里用了 await import() 动态加载------先把环境准备好再加载主逻辑,避免模块初始化顺序问题。

真正的分流在 run.ts,关键就一行:

c 复制代码
// apps/zcode-cli/packages/cli/src/run.ts
const commandName = (positionals: string[]): string => positionals[0] ?? "tui";

没输入任何子命令?那默认就是 tui。 后面是一个大 switch:-p 走 runPrompt()(一次性模式)、tui 走 runTuiCommand()、app-server 走协议服务(供桌面/Web 后端当子进程用)。

--web 的分流则更靠外,在发行包的启动器 scripts/zcode-distribution/runner.mjs 里:

arduino 复制代码
// scripts/zcode-distribution/runner.mjs(发行包 bin/zcode.mjs 的前身)
const argv = process.argv.slice(2);
// ......
} else if (argv[0] === "--web") {
  const options = parseArgs(argv.slice(1));
  // ......
  else await serve(options);   // 拉起 Web 服务器 + Agent 协议子进程
} else {
  process.argv[1] = agentEntry;          // 其余参数原样转交真正的 Agent CLI
  await import(pathToFileURL(agentEntry).href);
}

3.2 组装车间:createZCodeApp()

不管走哪条路,最终都会进同一个"组装车间"------bootstrap 包的 createZCodeApp()。这是全仓库最重要的一个函数,它把所有零件装配成一台完整的机器:

scss 复制代码
// apps/zcode-cli/packages/bootstrap/src/app/create-app.ts
runtime = new AgentRuntime(sessionId, runtimeConfig, {
  agentTelemetry: modelTelemetry.agentExecution,
  eventStore: options.eventStore ?? createInMemorySessionEventStore(),
  sessionStore,                    // SQLite 会话存储
  executionPort,                   // ★ Bash/进程执行
  fileSystemPort, httpClientPort,  // ★ 文件读写、HTTP 请求
  skillPort, mcpPort,              // 技能系统、MCP 客户端
  modelFactory,                    // ★ 模型工厂(Provider 层产物)
  permissionBroker,                // ★ 权限审批的"传话人"
  browserControlPort,              // 浏览器控制
  // ...... 还有十几个端口
});

看到那个对象字面量了吗?这就是六边形架构的具象化:AgentRuntime 内核只认"端口"(Port),每个端口由 adapters 包提供 Node.js 实现。内核里没有一行 child_process.spawn,没有一行 fs.readFile------这正是它能被完整单测的原因。

装配完成后,TUI 界面亮起,光标闪烁。启动完成。

3.3 启动时序图

scss 复制代码
用户zcode.cjsmain.tsrun.tsbootstrap/createZCodeAppTUI 界面在终端敲下 zcode1载入打包产物2进程改名、清洗环境变量3prepareCliProviderRuntimeEnv() 准备模型凭证4run(context)5parseGlobalArgs() 解析参数6runTuiCommand() → import("@zcode/tui")7渲染启动屏,加载会话与 git 分支信息8光标闪烁,等待输入9runPrompt()10createZCodeApp() 装配 Agent11输出结果,进程退出12启动 Web 服务器 + Agent 协议子进程13打开浏览器 http://127.0.0.1:端口14alt[无参数(默认 tui)][带 -p 任务描述(headless)][带 --web]用户zcode.cjsmain.tsrun.tsbootstrap/createZCodeAppTUI 界面

小知识:-p 模式下权限broker 是"默认全拒绝"(仅放行少量白名单工具),因为没人盯着屏幕点确认,宁可少干也不能乱干------这是个很值得学习的安全默认值。


四、心脏:Agent Loop,就是那个 while (true)

现在,你在 TUI 里输入:"帮我修复登录页的 bug",回车。

4.1 先讲原理:为什么必须是循环?

先补一个关键背景知识:大语言模型没有"行动力" 。它是一个纯函数------输入一串文本,输出一串文本,仅此而已。它不能执行命令、不能读文件、不能碰你的磁盘。

但模型厂商在训练时教会了模型一种特殊技能:当它想借助外部能力时,可以输出一段结构化的"工具调用请求" (tool call),形如:

json 复制代码
{
  "name": "Read",
  "input": { "file_path": "/app/login.tsx", "offset": 1, "limit": 50 }
}

这段话模型只是说出来 ,并不执行。谁执行?宿主应用 。宿主执行完,把结果作为一条 role: "tool" 的消息追加进对话,再次请求模型------模型看到结果,继续思考,可能又发起下一个工具调用......

于是产生了经典结构:

复制代码
请求模型 → 有工具调用? → 执行工具 → 结果塞回对话 → 再请求模型 → ...... → 没有工具调用 → 结束

这就是 Agent Loop。所有 Agent 框架(LangChain、Claude Code、OpenClaw......)的核心都是它,ZCode 也不例外------只不过它的这个循环被写到了相当的工程深度。

4.2 一条消息进门:executeTurn()

你按下回车后,TUI 调用 app.submitPrompt(),最终进入内核:

javascript 复制代码
// apps/zcode-cli/packages/core/src/runtime/methods/turn.ts
export async function executeTurn(
  this: AgentRuntimeInternal,
  input: string,
  attachments?: TurnState["attachments"],
  options?: ExecuteTurnOptions,
): Promise<TurnResult> {
  return await enqueueCancellableRuntimeCommand<TurnResult, PromptRuntimeCommand>(this, {
    abortSignal: options?.abortSignal,
    createCommand: ({ reject, resolve }) => ({
      attachments, createdAt: new Date(), id: createRuntimeCommandId(),
      input, mode: "prompt", options, priority: "next", reject, resolve, /* ... */
    }),
  });
}

第一个细节:你的消息不是直接开跑,而是先进一个可取消的命令队列。这意味着你可以在 Agent 干活中途插话(steering)、随时 Ctrl+C 取消,队列保证这些操作排队有序,不会打架。

出队之后进入 executeTurnCommand(),它做几件"开工前的事":冻结本轮使用的模型、初始化上下文(拼系统提示词)、触发 session/hook、把你的输入写入消息历史并落库 SQLite。

然后,主循环启动。

4.3 主循环本体:runRegularTurnLoop()

javascript 复制代码
// apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts
export async function runRegularTurnLoop(
  this: AgentRuntimeInternal,
  state: RegularTurnLoopState,
): Promise<void> {
  while (true) {                                        // ★ 就是它
    throwIfTurnAborted(state.turnAbortSignal);          // 每圈先看是否被取消
    // ......处理用户中途插话(steering)、后台子代理结果
​
    const compactPhase =
      state.modelStepCount === 0 ? CompactPhase.PreRequest : CompactPhase.MidTurn;
    await this.microcompactIfNeeded(/* ... */);           // 微压缩:清理旧工具结果
    const autoCompactOutcome = await this.autoCompactIfNeeded(/* ... */); // 快爆了就压缩
    // ......
​
    const tools = getTurnTools(this, state);            // 本步可用的工具清单
    // 把内部消息历史投影成"模型方言"的请求消息
    const providerProjection = buildRuntimeProviderRequestMessages(this, {
      entries: requestEntries, applyCacheControl: true, model: state.model,
    });
    const { messages } = providerProjection;
​
    const result = await runModelBackedTurnStep.call(this, state, { messages, tools, /* ... */ });
    if (result === "break") {                           // 没有工具调用 → 干完了
      break;
    }
    // 返回 "continue" → 有工具调用已执行完毕,回到 while 开头再来一圈
  }
}

刨去工程细节,骨架就是教科书级的 Agent Loop。但请注意循环体开头干的两件事------每圈循环前都会检查"菜单是不是太厚了" (上下文压缩,第八节细讲),这个细节是它长对话不崩的关键。

4.4 循环体:runModelBackedTurnStep()

每一圈干的事在这个函数里:

kotlin 复制代码
// apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step.ts(节选)
state.modelResponse = result.text;          // 本步模型输出的文字
state.modelStepCount += 1;
state.tokenCount += getModelUsageTotalTokens(result.usage);
​
let toolCalls = this.extractToolCallsFromResult(result);   // ★ 从回复中提取工具调用
​
const executableToolCalls = toolCalls.filter((toolCall) => !toolCall.providerExecuted);
// ......
if (executableToolCalls.length === 0) {
  // 大厨说"菜好了",没有要帮忙的动作 → 走收尾流程,返回 "break"
  return await finishModelStepWithoutToolCalls.call(this, state, { /* ... */ });
}
// 有工具调用:先把"带工具调用的 assistant 消息"提交进历史
// (这步至关重要------对话记录必须包含模型的调用请求,配对才完整)
if (commitAssistantToTurnRequest(this, state, result, executableToolCalls)) {
  recordModelHistoryRound(state);
}
const toolStepResult = await executeToolCallsForModelStep.call(this, state, {
  /* ... */ toolCalls: executableToolCalls,
});
return toolStepResult;    // "continue" → 回到 while(true),带着工具结果再请求模型

顺带一提,ZCode 还在循环外层用一个显式状态机守护回合生命周期(packages/core/src/agent/turn-machine.ts):

scss 复制代码
export interface TurnMachine {
  state: TurnState;
  start(): TurnState;
  startModelRequest(model: string, messages: ModelRequestState["messages"]): TurnState;
  receiveModelResponse(content: string): TurnState;
  scheduleTools(toolCalls: ToolCall[], schedule: ToolScheduleState): TurnState;
  startToolExecution(): TurnState;
  completeTool(toolCallId, result: { success: boolean; content: ModelMessageContent }): TurnState;
  aggregateResults(): TurnState;
  complete(response: string, resultType?: TurnResultType): TurnState;
  fail(error: TurnErrorState): TurnState;
  // ......
}

回合要依次经过 ProcessingInput → ModelRequest → Streaming → ToolScheduling → ToolExecution → AggregatingResults → Complete 这些相位,非法迁移直接抛异常。相当于给"领班"配了一本操作手册:你现在处于哪个阶段、下一步允许做什么,都有章可循------这是把"隐式状态"变成"显式状态"的教科书操作,能有效消灭一大类竞态 bug。

4.5 主循环时序图(本文最重要的一张图)

scss 复制代码
用户TUI 界面AgentRuntime(领班)模型 API(大厨)PermissionService(保安)工具执行器(帮厨)用户(审批弹窗)输入"帮我修复登录页 bug"并回车1submitPrompt() → executeTurn()2消息入队 → 拼系统提示词 → 用户消息落库 SQLite3检查上下文是否需要压缩(microcompact / autoCompact)4取本步工具清单,把历史投影成请求消息5streamText(messages, tools) 流式请求6流式事件:text_delta / tool_call / finish7文字增量实时上屏(你看到的"打字机效果")8本轮任务完成,返回 "break",跳出循环9ToolScheduler 拓扑排序 + 并行分组(只读可并行,上限 10)10checkPermission(工具名, 入参)11弹窗:"允许执行这条命令吗?"12允许 / 仅本次允许 / 拒绝13alt[判定为需要询问(如 Bash 执行命令)]放行 → 执行工具14工具结果(成功内容 或 报错信息)15结果以 role:"tool" 消息回填对话历史16回到循环开头,带着工具结果再请求模型alt[没有任何工具调用][有工具调用(可能多个)]loop[while(true)------Agent Loop]TurnResult(最终回复 + token 用量统计)17渲染完整回复18用户TUI 界面AgentRuntime(领班)模型 API(大厨)PermissionService(保安)工具执行器(帮厨)用户(审批弹窗)

把图和故事对上:领班(RT)在顾客、大厨、保安、帮厨之间机械地跑腿,直到大厨亲口说"菜好了"。


五、大厨怎么"直播"做菜:流式响应与事件归一化

5.1 流式是什么,为什么必须有

如果你用过 ChatGPT,一定见过文字一个词一个词往外蹦的效果。这就是流式(streaming) :模型服务端不等全文生成完,而是生成一点就推送一点(底层是 SSE,Server-Sent Events)。

对 Agent 来说流式不只是体验问题,还是效率问题:模型边"说话"边就把工具调用的参数说出来了,宿主可以提前听到"风声"。

5.2 统一事件语言:ModelStreamEvent

不同模型厂商的流式格式五花八门(Anthropic 一套、OpenAI 一套......)。ZCode 在 contracts 包里定义了自家的一套"普通话"------一个巨大的判别联合类型:

css 复制代码
// apps/zcode-cli/packages/contracts/src/model/index.ts(节选)
export type ModelStreamEvent =
  | { type: "start" }
  | { type: "text_start"; id: string }
  | { type: "text_delta"; id?: string; text: string }        // 文字增量
  | { type: "text_end"; id: string }
  | { type: "reasoning_start"; id: string; providerMetadata?: Record<string, unknown> }
  | { type: "reasoning_delta"; id?: string; text: string }   // 思考过程增量
  | { type: "tool_input_start"; id: string; toolName: string } // 要调工具了!
  | { type: "tool_input_delta"; id: string; delta: string }    // 工具参数逐段流出
  | { type: "tool_call"; toolCall: ModelToolCall }             // 工具调用完整成型
  | { type: "finish"; finishReason: string; usage: ModelUsage }
  | { type: "error"; error: unknown };
  // ......(原类型还有 compact_stream_boundary 等更多成员,此处节选)

TypeScript 的判别联合在这里大放异彩:消费方 switch (event.type),每个分支的类型自动收窄,漏处理一种事件编译器都会提醒你。

5.3 内核如何消费流:一个巨型 switch

内核消费流的代码在 packages/core/src/runtime/methods/model.ts,对每个事件做三路分发------累积、转发 UI、预执行:

csharp 复制代码
// apps/zcode-cli/packages/core/src/runtime/methods/model.ts(节选)
for await (const event of modelStream) {
  switch (event.type) {
    case "text_delta": {
      text += event.text;                              // ① 累积成完整回复
      options.onStreamTextDelta?.(event.text);         // ② 回调给流式协调器
      publishStreamSnapshot();                         //    快照给会话恢复用
      await enqueueStreamingEvent({                    // ③ 入队 → UI 实时渲染
        assistantMessageId: options.assistantMessageId,
        delta: event.text, done: false, kind: "text_delta",
      });
      break;
    }
    case "tool_call": {
      // 按 id 去重:防止兼容层重复投递同一次调用
      if (toolCallIds.has(toolCall.id)) break;
      toolCallIds.add(toolCall.id);
      toolCalls.push(toolCall);
      options.onStreamToolCall?.(toolCall);   // 只读工具可以在流中"预执行"!
      break;
    }
    case "finish": {
      finishReason = event.finishReason;
      usage = event.usage;                    // token 用量,记账本要用
      // ......
    }
  }
}

那个"预执行"很有意思:如果模型流式输出的第一个工具调用是 Read 这类只读工具 ,ZCode 会在模型还在继续生成后续内容时,就提前把它执行掉(packages/core/src/runtime/streaming-tool-coordinator.ts 中 STREAMING_TOOL_EXECUTION_MODE = "readOnly")。相当于大厨话说到一半,领班听到"冰箱第三层"就已经跑去翻了------流水线并行,省的就是真金白银的等待时间。

5.4 方言翻译官:Provider 适配层

那么各家的"方言"是谁翻译成这套普通话的?答案是 apps/zcode-cli/packages/adapters/src/model/ 下的适配层。ZCode 底层使用 Vercel 的 AI SDK(@ai-sdk/anthropic、@ai-sdk/openai-compatible 等,仓库里还带着对它们的 patch),支持三种 API 协议:

dart 复制代码
// packages/provider/src/config/provider-data-schema.ts
export const providerApiTypeDataSchema = z.enum([
  "anthropic-messages",        // Anthropic 协议
  "openai-chat-completions",   // OpenAI Chat 协议
  "openai-responses",          // OpenAI Responses 协议
]);

翻译官的工作非常朴素------一对一映射:

arduino 复制代码
// apps/zcode-cli/packages/adapters/src/model/runner-normalization.ts(节选)
export function toModelStreamEvent(chunk: TextStreamPart<ToolSet>): ModelStreamEvent | undefined {
  switch (chunk.type) {
    case "text-delta":
      return { type: "text_delta", id: chunk.id, text: chunk.text };
    case "reasoning-delta":
      return { type: "reasoning_delta", /* ... */ };
    case "tool-call":
      return { type: "tool_call", toolCall: { id: chunk.toolCallId, name, input } };
    case "finish":
      return { type: "finish", finishReason, usage: normalizeUsage(chunk.totalUsage) };
    // ......
  }
}

这就是"大厨不挑灶台":内核(core)完全不知道也不关心底层是 GLM、Claude 还是 GPT,换模型 = 换一份 Provider 配置,内核一行代码不动。


六、后厨团队:工具系统

循环里那句"执行工具",具体是怎么落地的?这一节拆工具系统。

6.1 每个工具都是一张"岗位说明书"

每个内置工具是一个 ToolEntry 对象,集中在 packages/core/src/tool/handlers/ 下(bash.ts、read.ts、edit.ts、grep.ts......),统一注册进 ToolRegistry。以 Bash 为例:

yaml 复制代码
// apps/zcode-cli/packages/core/src/tool/handlers/bash.ts(节选)
export const bashToolEntry: ToolEntry = {
  capability: "Execute platform shell commands through the execution adapter",
  metadata: {
    name: "Bash",
    description: BASH_PROVIDER_DESCRIPTION,   // 给模型看的工具说明书
    readOnly: false,                          // 不是只读
    destructive: false,
    concurrentSafe: false,                    // 不能并行执行
    sideEffectScope: "system",                // 有系统级副作用
    riskLevel: "high",                        // 高风险
    needsApproval: true,                      // ★ 需要审批
  },
  handler: bashHandler,                       // 真正干活的函数
  inputSchema: BashInputJsonSchema,           // JSON Schema(给模型)
  runtimeInputSchema: BashInputSchema,        // Zod Schema(给运行时校验)
  permission: {
    permission: "bash",
    reason: "Bash can run subprocesses and may affect workspace, git, network, or system state",
    riskLevel: "high", needsApproval: true,
    patternSources: ["command"],              // 按"命令内容"生成审批规则
  },
  resultBudget: { maxInlineBytes: MAX_INLINE_OUTPUT_BYTES, strategy: "artifact", /* ... */ },
  timeout: { /* ... */ cleanupGraceMs: 6_000 }, // 超时后有 6 秒宽限期清理子进程
  cancellation: { supported: true, /* ... */ },
};

一张说明书回答了五个问题:给模型的介绍(description + schema)、给运行时的输入校验(zod)、干活的函数(handler)、安全属性(风险等级/是否审批)、输出预算与超时策略。工具系统的一切后续行为------调度、权限、截断------都是从这张说明书里读出来的。

6.2 Bash:内核只"点菜",适配器才开火

Bash 的 handler 本体出乎意料地"干净":

csharp 复制代码
// apps/zcode-cli/packages/core/src/tool/handlers/bash.ts(节选,示意主干)
// 解析入参 → 构造 ExecutionRequest → 交给 executionPort
const result = await context.executionPort.run(request, runOptions);

真正的 spawn 藏在适配器 packages/adapters/src/exec/node-execution-adapter-process.ts 里,那里的代码就"脏"多了,也更真实:

javascript 复制代码
import { spawn } from "node:child_process";
// ...... prepareChildSpawn():构建环境变量、解析登录 shell、捕获 cwd
//     detached: platform !== "win32"、windowsHide: true
// ...... 超时杀进程树:
//     Windows → taskkill /T /F
//     POSIX  → 向整个进程组发信号(signalPosixProcessTree)

为什么连杀进程都要专门处理"进程树"? 因为如果你只杀直接子进程,它派生的孙进程会变成孤儿继续跑。taskkill /T /F 的 /T 就是"连坐整棵树"。这类只有真正跑过生产环境的人才写得出的细节,仓库里到处都是。

另外,run_in_background: true 时命令转入后台生命周期管理(输出可稍后读取),超时也会自动转后台而不是粗暴杀掉。

6.3 Read:不仅读,还"记仇"

Read 工具有个很妙的设计------readFileState 缓存:

arduino 复制代码
// apps/zcode-cli/packages/core/src/tool/handlers/read.ts
const FILE_UNCHANGED_STUB =
  "Wasted call --- file unchanged since your last Read. Refer to that earlier tool_result instead.";

如果模型反复读同一个没变过的文件,第二次开始直接返回这句"浪费调用"提醒,不再吐文件内容------既省 token 又治了模型的"强迫症复读"。同时这份缓存还是 Edit 的前置校验依据:没 Read 过的文件不许 Edit,防止模型闭眼盲改。

6.4 Edit:对暗号的修改术

Edit 工具不用行号、不用 diff,用的是"暗号"式修改:模型提供 old_string(原文片段)和 new_string(替换后的片段),运行时在文件里做唯一性匹配:

arduino 复制代码
// apps/zcode-cli/packages/core/src/tool/handlers/edit.ts(流程示意)
// fileSystemPort.readTextFile → normalizeLineEndings → findEditMatch 唯一性匹配
// not_found → OLD_STRING_NOT_FOUND:"String to replace not found in file"
// ambiguous → AMBIGUOUS_REPLACE:"Provide more surrounding context or set replace_all"

匹配算法在 packages/core/src/tool/edit-matchers.ts。这个设计非常聪明:

  • 强迫模型先"看"再"改" ------old_string 必须逐字来自真实文件,模型没法凭记忆乱改;
  • 匹配到多处就报错 ,让模型补充上下文或显式 replace_all,杜绝"改错地方";
  • 匹配前统一换行符,避免 Windows CRLF 把匹配搞挂。

6.5 调度器:只读的随便跑,动刀的排队走

模型经常一口气发出五六个工具调用("同时读 A、B、C 三个文件")。并行执行能大幅提速,但有副作用的工具并行会出事(比如两条 Bash 同时改 git 状态)。ZCode 的解法是一个带拓扑排序的调度器:

kotlin 复制代码
// apps/zcode-cli/packages/core/src/tool/scheduler.ts
const DEFAULT_MAX_CONCURRENCY = 10;
​
export class ToolScheduler {
  schedule(tools: ToolDependency[]): ToolSchedule {
    const items = tools.map((tool) => ({
      toolCallId: tool.toolCallId,
      toolName: tool.toolName,
      dependencies: tool.dependsOn,          // 模型可显式声明"等某某完成再跑"
      canRunParallel: this.canRunInParallel(tool),
      /* ... */
    }));
    const sorted = this.topologicalSort(items);   // 按依赖拓扑排序
    const groups = this.groupByParallel(sorted);  // 分成可并行的波次
    this.validateNoCycles(groups);                // 有环直接报错
    return { items, parallelGroups: groups, executionOrder: groups.flat() };
  }
​
  private canRunInParallel(tool: ToolDependency): boolean {
    // ......
    if (tool.destructive) return false;           // 破坏性工具:永不并行
    if (tool.concurrentSafe === true) return true;
    if (readOnly) return true;                    // 只读工具:放心并行
    return tool.sideEffectScope === "none";
  }
}

执行规则一句话:组内并行(上限 10 个)、组间串行。Read/Glob/Grep/WebSearch 这类只读工具自动归入可并行组,Bash/Edit 这类有副作用的自动串行。安全性不靠祈祷,靠说明书里的元数据 + 调度器硬约束。

6.6 MCP 工具:编外的帮厨也得穿同一套制服

ZCode 支持 MCP(Model Context Protocol,模型上下文协议)------一个让第三方程序给 AI 提供 tool 的标准协议。外面接入的 MCP 工具怎么进来?答案是:包装成和内置工具一模一样的 ToolEntry,注册进同一个 ToolRegistry:

arduino 复制代码
// apps/zcode-cli/packages/core/src/mcp/index.ts(要点)
// registerMcpTools(registry, mcpPort, descriptors, options)
//   → 每个 McpToolDescriptor 包装成 createMcpToolEntry()
//   → 命名规则:mcp__<服务器名>__<工具名>
//   → 权限默认 needsApproval: true(外来帮厨,默认从严)

于是在模型眼里,内置工具和 MCP 工具毫无区别;在权限系统眼里,外来工具默认更严格。扩展性靠"统一抽象"而不是"特殊通道" ,这是这个设计最值得学的地方。

6.7 Skills:给帮厨发的"专项作业指导书"

ZCode 还有一套 Skills 机制:一个技能就是一个带 SKILL.md 的文件夹(写明 name、description 和操作手册),放在 ~/.zcode/skills 或项目 .zcode/skills 下即被发现(发现逻辑在 packages/adapters/src/skills/index.ts 的 discoverSkills)。可用技能清单会注入到模型上下文里(有 2 万字符预算),模型按需用 Skill 工具加载全文------相当于帮厨接到专项任务时,才去档案柜抽出对应的作业指导书现读。


七、门口的保安:权限系统

工具能执行任意命令、改任意文件,没有权限系统的话,一句"帮我把项目清理一下"可能就 rm -rf 了。ZCode 的权限系统是个三值判定机:

kotlin 复制代码
// apps/zcode-cli/packages/core/src/permission/service.ts(节选)
checkPermission(context, toolCapability?, projectRules?, rulePolicy?): PermissionDecisionResult {
  const capability = this.resolveCapability(context, toolCapability);
​
  // 需要用户亲自交互的工具(如 AskUserQuestion),必须问
  if (capability.requiresUserInteraction) {
    return this.ask(context, capability, "tool.userInteraction",
      `Tool ${context.toolName} requires user interaction`);
  }
​
  // guarded 模式下,高危工具每次都要用户点头(user-once)
  if (!planEnabled && context.mode === "guarded" && toolCapability?.userApprovalRule) {
    return {
      ...this.ask(context, capability, toolCapability.userApprovalRule,
        "Dangerous command requires your approval for this execution"),
      approvalMode: "user-once",
    };
  }
  // ......
  // yolo 模式:全放行(放开手脚,后果自负)
  if ((context.mode === "yolo" || context.mode === "guarded") && !planEnabled) {
    return this.allow(context, capability, "mode.yolo",
      "Yolo mode bypasses permission prompts");
  }
  // 项目级规则:deny 优先,其次 ask
  if (this.matchesProjectRules(projectRules, "deny", context, capability, rulePolicy)) {
    return this.deny(/* ... */);
  }
  if (this.matchesProjectRules(projectRules, "ask", context, capability, rulePolicy)) {
    return this.ask(/* ... */);
  }
  // ......
}

判定结果只有三种:allow(放行)、ask(弹窗问用户)、deny(拒绝)。判定依据按优先级叠加:工具自身声明(needsApproval / riskLevel)→ 运行模式(plan / guarded / yolo)→ 项目规则 → 会话规则。

你在弹窗里点"本次会话总是允许",就写进 sessionRules,下次同类操作不再烦你。整个流程串起来是:

arduino 复制代码
工具调用 → PermissionService.checkPermission() 判定
        → "ask"? → PermissionBroker 转发给宿主 UI → 弹窗等你点
        → "allow" → 执行
        → "deny"  → 拒绝信息作为 tool_result 回给模型(模型会换个思路重试)

注意最后一点很关键:拒绝不是抛异常让整个回合崩掉,而是把"用户拒绝了你"作为工具结果告诉模型------模型会理解并换一种更稳妥的做法。这是 Agent 体验的细节功力。

顺带一提模式哲学:plan 模式只读不动手(适合先出方案)、guarded 模式危险动作逐次确认、yolo 模式全放行。默认档位宁严勿松。


八、菜单太厚了怎么办:上下文管理

8.1 问题:模型只有一张"桌子"

模型每次请求能"看到"的内容总量是有限的------上下文窗口。ZCode 默认按 20 万 token 的窗口规划。而一个真实的编码任务,动辄要读几十个文件、跑十几条命令,工具结果塞进历史后膨胀极快。桌子就这么大,菜越上越多,怎么办?

ZCode 给了三层答案。

8.2 系统提示词:店规按需挂载

首先,那本"店规"(系统提示词)不是一整块,而是按段落拼装(packages/core/src/context/builder.ts 的 ContextBuilder.build()):

arduino 复制代码
// apps/zcode-cli/packages/core/src/context/builder.ts(节选)
sections.push(buildCliPrefixSection());        // "You are ZCode......"开场白
sections.push(buildIdentitySection(/* ... */));  // 身份与行为准则
sections.push(buildEnvInfoSection(this.config.envInfo, this.config.model)); // 环境信息
sections.push(buildContextManagementSection());
// ...... 还有项目 memory、技能清单、当前日期等段落

而且 stable 段与 dynamic 段分开成多条 system 消息、打上缓存锚点(cache control)------不变的内容命中提示词缓存,省钱省延迟。

8.3 微压缩:不花钱的"桌面收纳"

每圈循环开头都会跑 microcompactIfNeeded()(packages/core/src/compact/microcompact.ts):把较老的大体积工具结果 (比如 10 分钟前 cat 出来的 500 行日志)就地替换为占位符:

sql 复制代码
[Old tool result content cleared]

默认保留最近 5 条工具结果原文。关键是------这一步完全不调用模型、不花钱,纯粹是本地收纳。旧工具结果对后续推理的价值通常很低,但对 token 的占用却是实打实的。

8.4 自动压缩:桌子真满了,请大厨写摘要

当估算 token 逼近窗口上限(packages/core/src/compact/policy.ts):

ini 复制代码
// apps/zcode-cli/packages/core/src/compact/policy.ts
export const DEFAULT_COMPACT_CONTEXT_WINDOW = 200_000;
export const DEFAULT_AUTOCOMPACT_OUTPUT_RESERVE_TOKENS = 32_000; // 给输出留 32K
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000;                 // 13K 安全缓冲
export const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3;

autoCompactIfNeeded()(packages/core/src/runtime/methods/compact.ts)会把整段对话历史交给模型:"请把这些工作过程总结成结构化摘要",然后用摘要替换掉原始历史 ,对话在一个干净的小历史上继续。压缩失败连续 3 次会熔断;如果模型还是报"超窗"了,还有 recoverModelStepAfterContextExceeded() 做反应式抢救压缩。

8.5 记账本:SQLite

这一切对话历史并不是只活在内存里。会话持久化用的是 Node.js 24 内置的 node:sqlite(无需原生编译依赖):

typescript 复制代码
// apps/zcode-cli/packages/adapters/src/storage/session-store/sqlite-session-store.ts
export class SqliteSessionStore
  implements SessionStorePort, InputHistoryStorePort, LocalSettingStorePort, /* ... */
{
  private readonly db: DatabaseSync;   // node:sqlite
  constructor(options: SqliteSessionStoreOptions = {}, /* ... */) {
    this.dbPath = options.dbPath ?? getDefaultSessionDbPath();  // ~/.zcode/cli/db/db.sqlite
    // ......
  }
}

每条用户消息、assistant 消息(含 reasoning/text/tool part)实时落库,所以你随时能 zcode -c 续上一场隔夜的对话,或 zcode --resume 挑一场旧会话恢复。


九、大厨的徒弟:子代理

最后一个大件。复杂任务(比如"把整个仓库的鉴权实现摸清楚")如果让主对话自己干,工具结果会塞满主菜单。ZCode 的解法是 Agent 工具(子代理) :

javascript 复制代码
// apps/zcode-cli/packages/core/src/runtime/methods/subagent.ts(要点)
const childRuntime = new AgentRuntime(
  request.sessionId,
  { mode: /* ... */,
    subagentContext: { agentPrompt: agentPrompt ?? "", /* ... */ },
    maxTurns: request.maxTurns ?? this.config.subagents?.maxTurns ?? 4,  // 默认最多 4 轮
    taskType: "subagent_child",
    toolset: builtInExplore ? "explore" : "main",   // 受限工具面
    /* ... */ },
  { /* ... */ sessionStore: deps.sessionStore, modelFactory: childModelFactory, /* ... */ });
// ......
return await childRuntime.executeTurn(request.prompt, undefined, { /* ... */ });

看清发生了什么:子代理 = 一个全新的 AgentRuntime 。它有独立的子会话(session id 形如 subagent_xxx)、自己更小的工具面(探索型只给 Grep/Read 这类只读工具)、自定义系统提示词、默认最多 4 轮的"体力上限"。然后它复用同一个主循环跑任务,把最终回答文本 作为 Agent 工具的 tool_result 交还父会话。

对模型来说就像派了个徒弟:把杂活整个打包丢出去,只收一张结论纸条回来,主菜单始终干净。子代理的工具调用事件还会"镜像"到父会话 UI(mirrorSubagentToolEvent),所以你在界面上能看到徒弟在干嘛,不会觉得它摸鱼。

vbscript 复制代码
父 AgentRuntime(领班)模型(大厨)Agent 工具子 AgentRuntime(徒弟)子工具面(只读检索)下一圈请求1tool_call:Agent(prompt="梳理仓库的权限实现")2执行 Agent 工具3new AgentRuntime(受限工具 + 专属系统提示 + 最多 4 轮)4携带只读检索工具的请求5tool_call(Grep / Read / Glob)6执行7结果回填8loop[子循环(徒弟自己的 while(true))]最终回答(纯文本结论)9作为 tool_result 回填父会话10父对话只多了一条紧凑结论,不被过程数据淹没父 AgentRuntime(领班)模型(大厨)Agent 工具子 AgentRuntime(徒弟)子工具面(只读检索)

十、彩蛋:这个仓库的"工程师性格"

最后分享几个读源码时让我会心一笑的细节,算是这个项目的"性格侧写":

  1. 模型也会犯病,运行时得哄着 。turn-model-step.ts 里有专门的 isSuspiciousEmptyModelResult(模型返回可疑空结果)、防重复工具调用去重、超窗自动抢救压缩......处处是对"模型不靠谱"的防御性编程。Agent 工程的一半工作量,是在给模型的"人类小毛病"擦屁股。
  2. 连权限都有白名单特例的注释 。permission/service.ts 里有一段针对 ReplyToChannel 的特判,注释写明"network 副作用不能让这个免审批契约重新弹窗......放在显式 deny/ask 之后"------每个特例都交代了为什么、放哪儿、防什么。
  3. 形式化验证 。仓库里有个 packages/formal-proof 包,用 TLA+ 加有界 TypeScript 检查器去验证 Provider 运行时的不变量。开源 Agent 项目里玩形式化方法的,属实稀有。
  4. 敢自黑的文档 。docs/ 里那篇架构审计报告直接写"CI 测试 job 全部被禁用""fix : feat ≈ 3.7 : 1,一半以上工作量在修 bug",然后逐条给出治理方案。能把自家问题写这么狠的团队,通常也真的在还债。
  5. 对上游 SDK 打补丁 。patches/ 目录下有对 @ai-sdk/anthropic、@ai-sdk/openai-compatible 的 patch 文件(比如给视频输入补 content block 投影),用 pnpm 的 patch 机制管理------不 fork 不魔改,补丁透明可审。

十一、总结:把整头象拼回原样

回顾一下这头"大象"的全貌:

  • 一个大厨(模型) :只会动嘴,输出文本和工具调用请求;
  • 一个领班(AgentRuntime + while(true)) :把大厨的话变成行动,把行动结果变回大厨的输入,循环至完工;
  • 一支帮厨队伍(ToolRegistry) :Bash/Read/Edit/Grep......每个都带"岗位说明书"(schema + 权限 + 超时 + 输出预算);
  • 一个保安(PermissionService) :allow / ask / deny 三值判定,危险的必须用户签字;
  • 一套翻译官(Provider 适配层) :三家 API 方言统一成 ModelStreamEvent 普通话,换模型不动内核;
  • 一本收纳术(compact) :microcompact 免费收纳 + autoCompact 花钱总结,长对话不撑爆;
  • 几个徒弟(子代理) :杂活外包,结论回收;
  • 一本记账本(SQLite) :随时续命、随时回放。

如果只能记住一句话,请记住这句:

Agent = 模型 + 工具 + 循环 + 权限。模型提供"智能",循环提供"行动力",工具提供"手脚",权限提供"安全带"。 其余几十万行代码,都是围绕这四件事的工程化------防崩溃、防竞态、防超支、防越权。

想动手的读者,仓库已经在 github.com/zai-org/ZCo...,本文所有路径都可直接对照:

bash 复制代码
git clone https://github.com/zai-org/ZCode.git
cd ZCode
# Node.js 24.14.0 + pnpm 10.33.2(版本以仓库 mise.toml 为准)
pnpm bootstrap

建议按这条路径读源码,由浅入深:

  1. apps/zcode-cli/packages/cli/src/main.ts → run.ts(入口与分流)
  2. apps/zcode-cli/packages/bootstrap/src/app/create-app.ts(组装车间)
  3. apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts(★ 主循环)
  4. apps/zcode-cli/packages/core/src/tool/handlers/(挑 Bash 和 Edit 两个精读)
  5. apps/zcode-cli/packages/core/src/permission/service.ts(权限判定链)
  6. apps/zcode-cli/packages/core/src/compact/(上下文管理)

读代码这件事,和 Agent 干活一样:先 Read,再 Edit。祝玩得开心。

相关推荐
xz2024102****2 小时前
agent基础知识:function calling,MCP,和Skill
大数据·人工智能·agent
李溪白2 小时前
RAG 进阶:Agentic RAG 与多智能体协作(系列二·第2篇):长上下文优化
agent
测试开发技术3 小时前
AI时代我们要如何玩抓包测试:Reqable + AI Agent
软件测试·人工智能·测试工具·接口测试·agent·抓包
米小虾3 小时前
工具设计:为什么"多给工具"是个陷阱
人工智能·agent
米小虾3 小时前
ReAct:Agent 是怎么"边想边做"的
人工智能·agent
枫叶丹44 小时前
知识库为什么搜不到答案:多向量检索与 Late Interaction
人工智能·chatgpt·开源·agent·codex
云半S一4 小时前
Agent学习总结二
经验分享·笔记·学习·agent
杨杨杨大侠11 小时前
RAG、记忆、上下文,这几个东西到底怎么串起来?
人工智能·agent