本文基于开源仓库 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 本体,把它的实现原理掰开揉碎讲清楚。
读完本文你将搞明白四件事:
- AI Agent 的"发动机"——Agent Loop 到底长什么样;
- 模型明明只会输出文字,是怎么"执行命令、改文件"的;
- 为什么它不会把你的电脑搞炸——权限系统的设计;
- 对话越来越长,它是怎么不"失忆"也不"爆内存"的。
别担心,我们用一个餐厅的故事开胃,再逐层下源码。不需要任何 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(仓库套仓库),顶层结构如下:
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 命令有三种玩法:
zcode # 无参数 → 进入终端交互界面(TUI)
zcode -p "帮我统计代码行数" # -p → 一次性任务,干完就退出(headless)
zcode --web # → 启动 Web 服务,浏览器里用
分流逻辑在哪?先看入口。apps/zcode-cli/packages/cli/package.json 声明了 bin:
"bin": {
"zcode": "./dist/zcode.cjs"
}
zcode.cjs 是把 apps/zcode-cli/packages/cli/src/main.ts 用 esbuild 打成的单文件。main.ts 开头是一串"开店前的准备":
// 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,关键就一行:
// 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 里:
// 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()。这是全仓库最重要的一个函数,它把所有零件装配成一台完整的机器:
// 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 启动时序图
用户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),形如:
{
"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(),最终进入内核:
// 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()
// 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()
每一圈干的事在这个函数里:
// 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):
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 主循环时序图(本文最重要的一张图)
用户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 包里定义了自家的一套"普通话"——一个巨大的判别联合类型:
// 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、预执行:
// 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 协议:
// 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 协议
]);
翻译官的工作非常朴素——一对一映射:
// 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 为例:
// 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 本体出乎意料地"干净":
// 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 里,那里的代码就"脏"多了,也更真实:
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 缓存:
// 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(替换后的片段),运行时在文件里做唯一性匹配:
// 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 的解法是一个带拓扑排序的调度器:
// 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:
// 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 的权限系统是个三值判定机:
// 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,下次同类操作不再烦你。整个流程串起来是:
工具调用 → 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()):
// 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 行日志)就地替换为占位符:
[Old tool result content cleared]
默认保留最近 5 条工具结果原文。关键是——这一步完全不调用模型、不花钱,纯粹是本地收纳。旧工具结果对后续推理的价值通常很低,但对 token 的占用却是实打实的。
8.4 自动压缩:桌子真满了,请大厨写摘要
当估算 token 逼近窗口上限(packages/core/src/compact/policy.ts):
// 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(无需原生编译依赖):
// 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 工具(子代理) :
// 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),所以你在界面上能看到徒弟在干嘛,不会觉得它摸鱼。
父 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(徒弟)子工具面(只读检索)
十、彩蛋:这个仓库的"工程师性格"
最后分享几个读源码时让我会心一笑的细节,算是这个项目的"性格侧写":
- 模型也会犯病,运行时得哄着。
turn-model-step.ts里有专门的isSuspiciousEmptyModelResult(模型返回可疑空结果)、防重复工具调用去重、超窗自动抢救压缩……处处是对"模型不靠谱"的防御性编程。Agent 工程的一半工作量,是在给模型的"人类小毛病"擦屁股。 - 连权限都有白名单特例的注释。
permission/service.ts里有一段针对ReplyToChannel的特判,注释写明"network 副作用不能让这个免审批契约重新弹窗……放在显式 deny/ask 之后"——每个特例都交代了为什么、放哪儿、防什么。 - 形式化验证。仓库里有个
packages/formal-proof包,用 TLA+ 加有界 TypeScript 检查器去验证 Provider 运行时的不变量。开源 Agent 项目里玩形式化方法的,属实稀有。 - 敢自黑的文档。
docs/里那篇架构审计报告直接写"CI 测试 job 全部被禁用""fix : feat ≈ 3.7 : 1,一半以上工作量在修 bug",然后逐条给出治理方案。能把自家问题写这么狠的团队,通常也真的在还债。 - 对上游 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…,本文所有路径都可直接对照:
git clone https://github.com/zai-org/ZCode.git
cd ZCode
# Node.js 24.14.0 + pnpm 10.33.2(版本以仓库 mise.toml 为准)
pnpm bootstrap
建议按这条路径读源码,由浅入深:
apps/zcode-cli/packages/cli/src/main.ts→run.ts(入口与分流)apps/zcode-cli/packages/bootstrap/src/app/create-app.ts(组装车间)apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts(★ 主循环)apps/zcode-cli/packages/core/src/tool/handlers/(挑 Bash 和 Edit 两个精读)apps/zcode-cli/packages/core/src/permission/service.ts(权限判定链)apps/zcode-cli/packages/core/src/compact/(上下文管理)
读代码这件事,和 Agent 干活一样:先 Read,再 Edit。祝玩得开心。