ARTICLE · 1053095
拆开 ZCode 源码,Agent 真正难的,不是调用大模型!

设想一个场景:你让 Coding Agent 修复一个 bug。它读了文件,改了函数,正在运行测试。这时,你补充了一句:“不要改公开 API。”紧接着,模型连接断了。
问题来了:刚才的修改还在吗?测试究竟跑没跑?那句新要求应该进入当前任务,还是排到下一轮?重新连接后,会不会把已经做过的操作再做一遍?
这些问题,光靠换一个更强的模型,或者写一段更长的提示词,并不能自动解决。
它们发生在模型之外,却直接决定了 Agent 是否值得信任。
Z.ai 的 ZCode 公开仓库,提供了一个观察窗口:这里不只有聊天界面,还包括桌面与 Web 工作台、终端 Agent,以及支撑执行的运行时源码。
ZCode/├── packages/ 产品工作台及跨端基础设施│ ├── desktop/ Electron 桌面端│ ├── web/ 浏览器端│ ├── server/ Web/远端宿主相关后端│ ├── zcode-server-cli/ 分发与启动入口│ ├── ui/ 共享界面、Hooks、状态管理│ ├── services/ 服务层│ ├── rpc/ RPC 基础设施│ ├── client/ 客户端服务访问、连接│ ├── shared/ 跨端类型、协议及纯函数│ ├── provider/ Provider/模型选择相关抽象│ └── provider-node/ Node 环境实现│└── apps/zcode-cli/packages/ Agent 运行系统├── cli/ 命令行解析与入口分发├── tui/ 终端交互界面├── bootstrap/ 配置解析、依赖装配、应用门面├── contracts/ 领域契约、Schema、事件和端口├── core/ Agent 运行时与执行策略├── adapters/ 模型、文件、进程、存储等实现├── dynamic-workflow/ 工作流编译、分析与引擎├── dynamic-workflow-runtime/ 工作流运行支撑├── telemetry/ 可观测性└── … 插件、Node REPL、国际化等
读懂 ZCode,不只是找到调用模型的函数。更要看清:谁拥有状态,谁推进执行,失败后谁负责恢复。
01 先看懂它:工作台在外,运行时在内

第一次打开仓库,很容易被目录数量带偏。把整个项目当成一个 CLI,会漏掉宿主与多端连接;从 UI 组件一路向下读,又容易把真正的 Agent 内核淹没在界面细节里。更有效的办法,是先把它分成两层。
外层:让用户能够使用 Agent
根目录下的 packages/ 包含 Desktop、Web、Server、共享 UI、业务服务、RPC 和客户端连接等部分。它们处理界面呈现、平台能力与服务访问。
内层:让 Agent 能够执行任务
apps/zcode-cli/packages/ 里则有 CLI、TUI、Bootstrap、Contracts、Core 和 Adapters。入口接收任务,Bootstrap 装配依赖,Core 决定执行流程,Adapters 对接模型、文件、进程和存储。
这里的 Bootstrap 值得先读。createZCodeApp() 会组织模型工厂、会话存储、权限服务、文件系统、命令执行、MCP、技能和产物存储,再把这些能力交给 AgentRuntime。
可以把 AgentRuntime 理解为一个会话的执行中枢。它并不需要自己知道每一种文件系统或模型接口怎么调用,而是依赖明确的端口,由外部注入具体实现。
内核回答“做什么、何时做、能不能做”;适配器回答“在这个环境里,具体怎样做”。
这也是二次开发时的第一条路线:接入新模型、新存储或新执行环境,先找 Adapter 和装配入口,不要急着往主循环里加平台判断。
源码锚点|bootstrap/src/app/create-app.ts;core/src/runtime/agent-runtime.ts。
02 输入不是发出去就算开始:先解决谁能开工

回到开头的场景。Agent 正在执行,你又发来一条要求。最简单的实现,是把这句话追加到 messages 数组。但真实系统不能这么随意。
它必须先决定:这是引导当前任务的补充输入,还是下一轮任务?当前任务能不能接受引导?有没有附件?是否需要拒绝本次提交?
在 ZCode 中,输入门面会把这些执行接纳问题交给 Runtime 的 admitPrompt(),而不是由界面或 Bootstrap 各自判断会话忙不忙。忙碌时,代码根据当前状态与 delivery 配置选择引导、排队或拒绝;空闲时,先预留 Turn,再把命令放入运行时队列。
为什么“先预留”这么重要?
假设请求 A 检查到空闲,然后开始异步初始化。在初始化结束前,请求 B 也进来了。如果“已经接纳一个任务”这件事还没被记录,B 就可能再次启动同一个会话。
admitPrompt() 把启动预留放在后续执行之前,正是在封住这个异步窗口。它保护的不是一个按钮,而是会话的执行边界。
与此同时,接纳回执和执行结果是分开的。代码可以先返回 turnId 与 completion,让调用方知道任务已被接纳,再等待执行事件和最终结果。
输入被接纳,不等于模型已经开始输出;模型开始输出,也不等于任务已经完成。
还有一个容易忽略的细节:executeTurnCommand() 在第一次 await 之前,会固定本轮模型选择与输出风格。用户在初始化期间切换模型,不应悄悄改变已经进入执行边界的这一轮。
这给 Agent 产品设计了一条清楚的规则:配置可以变化,但正在执行的任务必须有自己的配置快照。
源码锚点|bootstrap/src/app/input-facade.ts;core/src/runtime/methods/prompt-admission.ts、turn.ts。
03 核心仍然是循环,难的是每一个边界

Agent 的基本循环并不神秘:准备上下文,请求模型,执行工具,把结果放回上下文,然后继续。ZCode 的主线,同样可以在 runRegularTurnLoop() 中找到。
以下是解释流程的伪代码,不是原样源码。准备上下文与本轮可用工具→ 请求模型,接收流式事件→ 有工具调用:执行、记录结果,再继续→ 需要恢复或续写:进入对应分支→ 满足结束条件:收口本轮任务
但阅读时,必须区分四个层级:Session 是可持续、可恢复的会话;Turn 是一次任务执行;Model Step 是 Turn 内的一次模型请求;Tool Call 则是某次具体工具调用。一个 Turn 可以包含多个 Model Step。
ZCode 没有把整个任务生命周期交给模型 SDK。默认模型接入使用 AiSdkModelAdapter,但主循环、工具调度、上下文压缩、异常恢复等控制流程,仍由自己的 Core 持有。
“不要改公开 API”应该什么时候进入?
在运行中补充要求,并不意味着可以在任何时刻改写历史。主循环在特定边界接收运行时消息和子任务结果;guide 输入的处理也要与完整工具结果批次衔接,避免把下一轮任务错误地吞进当前执行。
这背后有一个很实际的需求:同样一句话,在“模型正在输出工具参数”和“工具结果已经全部返回”这两个时刻,适合采取的处理并不相同。
普通主循环也不是“最多跑十轮”的示例程序。它持续推进,并依靠取消、预算、超时和恢复策略等具体条件控制执行。其他子系统仍有各自的限制,不能因此理解成无限制运行。
源码锚点|core/src/runtime/methods/turn-loop.ts、turn-model-step.ts。
04 最难的一幕:工具已执行,模型却断线了

如果只挑一段机制深入研究,我会选流式工具协调器:streaming-tool-coordinator.ts。
常见的演示程序会等模型完整输出结束,再统一执行工具。ZCode 则可以在收到符合条件的流式工具调用后提前执行,让部分工具等待与模型输出重叠。
提前执行不是“看到工具名就开跑”。代码要求流式配置开启,并检查工具只读、并发安全、无破坏性、不需要审批或用户交互,而且有效副作用范围为 none。条件需要共同成立。
这一机制有优化等待时间的意图,但具体节省多少,必须实测。更值得注意的是:提前执行以后,系统也必须承担提前执行带来的恢复复杂度。
一旦断流,不能简单地“整轮重来”
设想模型已经声明了几个工具调用:第一个有了结果,第二个还在运行,第三个只收到了声明、尚未执行。这时模型连接断开。
协调器会跟踪已接受的调用、执行句柄和已经取得的结果。恢复时,确定的结果被保留;没有确定结果的调用会得到结构化的合成结果,区分 not_executed 与 unknown_execution_state。
未知状态不能被包装成成功;已经确定的结果,也不应在恢复时被随意丢弃。
接下来还有一件事:把 assistant 的工具调用声明和对应工具结果配对接回请求历史。否则,模型下一次看到的可能是一段结构不完整的对话:有结果,却找不到产生它的调用。
这里体现的不是一个简单的重试开关,而是对执行事实的管理:什么发生过,什么没发生,什么还无法确定。
用户点击 Stop,也不是调用一次 abort 就结束
取消时,代码还要保存已产生的部分输出,记录工具取消或放弃状态,并协调运行中的历史与之后的冷恢复。执行可以停止,但停止之前发生的事实不能一起消失。
不过,必须守住结论边界:这不等于对所有文件、进程和外部服务提供了“精确一次执行”保证。它提供了跟踪与恢复处理,不是一个包住全部外部副作用的全局事务。
源码锚点|core/src/runtime/methods/streaming-tool-coordinator.ts、turn-model-step.ts。
05 工具不是函数,而是一份执行契约
当模型决定“改一个文件”,运行时真正需要知道的,远不止工具名称和入参。
这个工具会不会写数据?能否并发?需不需要确认?多久算超时?取消后怎样清理?输出太大怎么办?这些问题,都应该进入工具契约,而不是散落在调用点。
以 editToolEntry 为例,源码同时声明了模型与运行时 Schema、只读和并发属性、副作用范围、风险与审批信息、结果预算,以及超时、取消和 Trace 规则。
从模型意图,到真实副作用,中间是一条流水线
executeToolCall() 会经历工具查找、输入规范化、Schema 与语义检查、实际输入解析、PreToolUse Hook、权限判断和用户确认,再进入带超时与取消控制的 Handler,最后校验并收口结果。
尤其值得注意的是 resolveInput 的位置:它在 Hook 和权限判断之前,把输入解析成真正要执行的内容。这样,策略、确认界面和 Handler 才尽可能面对同一份执行事实。
比如,用户批准的不能只是“运行某个脚本引用”,而实际执行时却悄悄变成另一段已经展开、但从未给用户确认的脚本。
为什么 Edit 不是一次字符串替换?
默认 Runtime 维护文件读取状态。修改现有文件时,Edit 会检查读取记录和文件状态,再进行匹配;替换不唯一会被拒绝,除非明确要求全部替换。它还处理换行等细节。
它要防止的是:Agent 读完文件以后,用户或格式化器又改了一遍,Agent 却按旧内容继续覆盖。
工具调度器同样不是无脑 Promise.all。它根据传入的依赖关系做拓扑排序,再结合安全元数据划分并行批次;默认最大并发为 10。这个数字不是任务最多调用工具的次数,也不意味着调度器自动理解任意文件操作之间的语义依赖。
第一次运行之前,务必看清权限默认值
在本文分析的快照中,headless 的 --prompt 分支未显式指定模式时,默认使用 yolo。对没有先进入特殊交互或强制确认分支的普通工具,权限服务中的非 Plan yolo 放行分支,位于普通禁止列表和项目规则检查之前。
这是权限服务这一层的顺序,不代表它能够重新启用已从工具可见性中移除的能力。但它足以提醒我们:不要凭模式名称或界面印象,猜测真实授权行为。
权限审批不等于操作系统沙箱。进程、文件与网络隔离,必须继续检查具体实现和部署环境。
研究运行行为时,更稳妥的做法是显式指定权限模式,并使用可丢弃的工作区,而不是直接把重要仓库交给默认参数。
源码锚点|core/src/tool/executor/call-runner.ts、tool/handlers/edit.ts、tool/scheduler.ts;core/src/permission/service.ts;cli/src/run.ts。
06 长任务的另一半:管理上下文,也管理历史
任务越长,Agent 越可能遇到上下文压力。但“把最旧的消息删掉”不是完整答案:删掉的可能恰好是用户提出的关键约束。
ZCode 的上下文来源不只是一条 System Prompt。环境信息、用户指令、项目上下文、技能、项目 Memory、模型能力和工具引导等,都会参与上下文构造。
先清理工具结果,再按需做摘要
在配置启用时,Microcompact 可以本地清理较旧的工具结果正文,保留较新的结果组,并保护特定错误与媒体内容。它不是再请求一次模型,而是一种本地减负机制。
完整压缩则涉及模型摘要与压缩边界:哪些内容被总结,哪些片段保留,压缩前后占用如何变化。自动压缩阈值还会先预留输出空间与缓冲,而不是等输入把窗口完全塞满再处理。
项目 Memory 又是另一回事。当前上下文路径会读取项目记忆目录中的 MEMORY.md。压缩解决“这段会话太长怎么办”,Memory 解决“未来有哪些项目知识可以复用”,不能把两者混成一个“永久记忆”概念。
真正容易出问题的,是三种历史不一致
源码里至少要区分三种表示:Runtime 内部历史、某次实际模型请求的消息投影,以及持久化的消息、Part 和会话数据。
三种表示不能简单共用一个可随意修改的数组。自动续写提示属于请求恢复机制,不应被当成普通用户消息永久写回;聊天正文也不一定包含模型请求的全部内容。
模型 Step 会在请求前保存 assistant 消息与 step-start 信息;流式工具执行会保存相应的 pending/running Part。恢复依赖的是这些中间事实,而不只是任务结束后的最终答案。
还要避免一个术语误判:默认装配中的 EventStore 可以是内存实现,会话存储则使用 SQLite 并执行启动迁移。因此,更准确的说法是“事件驱动、状态投影和会话持久化相结合”,而不是未经核对就称它为纯事件溯源系统。
可恢复,不只是重新打开一份聊天记录,而是让恢复后的系统正确理解:哪一步结束了,哪一步失败了,哪一步仍然不确定。
源码锚点|core/src/runtime/methods/context.ts;core/src/compact/;bootstrap/src/app/session-store.ts。
07 多 Agent 不是多开几扇聊天窗
在 ZCode 里,子 Agent、动态工作流和 Goal/Target,是三种相关但不同的抽象。把它们都叫“多 Agent”,反而容易看不清结构。
子 Agent:一个受约束的子运行时
默认子任务路径会创建新的 AgentRuntime,拥有自己的 Session 身份,并解析或继承模型选择、工具范围、权限、MCP 与环境信息。面向用户的阻塞式交互,则通过派生端口路由回父会话。
默认子 Runtime 还会关闭继续派生子 Agent 的能力,避免默认无限递归。Explore 与通用或自定义 Agent 的权限和工具处理也不完全相同。
但独立 Session 不等于独立进程,更不等于独立文件系统。需要隔离工作区时,必须另外检查相应机制,不能仅凭“多 Agent”这个功能名称就放心。
动态工作流:把协作提升为独立子系统
dynamic-workflow 包含脚本编译、静态分析、Schema 合成、转换、WorkflowEngine、Journal 与产物管理。它不只是并行发出几个模型请求,而是在显式组织执行与协作。
目标模式:在普通任务外再加一层循环
Goal/Target 层可以推进目标,等待一轮执行后进行完成验证,再决定是否继续。它与 Turn 内部的“模型—工具”循环不是一回事。
同样需要克制:目标验证器会利用模型读取历史做判断,并不等于重新执行测试,更不是形式化证明。真正的完成质量,仍应由可检查的产物和测试证据支撑。
源码锚点|core/src/runtime/methods/subagent.ts、target-continuation-loop.ts、target-completion-verification.ts;dynamic-workflow/src/index.ts。
08 插件的价值,在扩展能力,也在守住边界
Tool、MCP、Skill、Hook 和 Plugin 看上去都在“给 Agent 加功能”,但它们站在不同层次。
Tool 是可执行能力;MCP 接入外部工具服务;Skill 提供任务知识与操作指导;Hook 介入生命周期与策略;Plugin 则把命令、技能、Agent、Hook 和 MCP 等组织起来进行分发。
MCP 发现的工具会进入 Runtime 的工具 Registry,并应用相应的允许与禁止列表。这意味着外部工具不应该被理解成绕开统一工具治理的一条捷径。
还有一个会影响使用体验的细节:当前 Session 使用的技能目录与插件引用目录具有快照语义。运行中的界面应与 Runtime 已发现的能力一致;已有会话不会因为安装新插件,就自动热加载整套新能力。
“插件已安装”和“这个运行中的会话能用它”,不是同一个状态。
这个取舍不一定适合所有产品,但它说明了一种工程选择:宁可明确能力更新边界,也不要让同一会话在执行中不知不觉更换能力集合。
09 从这份源码里,真正应该带走什么?
如果你也在开发 Agent,不必照搬 ZCode 的整个目录结构。我更建议先吸收四条原则。
第一,执行事实只能有一个权威来源
界面可以有草稿和乐观显示,但任务是否忙碌、输入是否被接纳、操作是否完成,必须有明确的状态所有者。否则,多端和异步会很快把系统推向相互矛盾的状态。
第二,先设计失败路径,再优化顺利路径
在工具执行前失败、执行中断线、执行后但尚未确认时退出,是三种不同情况。只写一个 catch 然后整体重试,通常无法解释这三种事实。
第三,把副作用写进契约,不要只写进描述
是否只读、能否并发、需要什么权限、取消后如何处理,都应该被运行时理解。面向模型的自然语言描述,不能替代执行层的结构化约束。
第四,测试不只检查答案,也检查状态
可以先做四个很小的实验:同时提交两个输入,检查是否争抢同一执行权;在工具执行后模拟断流,检查是否保留调用与结果配对;Stop 后重开会话,检查部分输出和工具状态;Read 后从外部修改文件,再检查 Edit 的行为。
这些是建议的验证场景,不是本文已经跑过的测试。它们关注的,是用户真正会遇到的执行一致性问题。
阅读源码,也沿着同一条任务走
选一个很小的任务:“读一个文件,改一个函数,运行一个测试。”然后沿着输入门面、admitPrompt()、turn-loop.ts、模型 Step、工具执行器和会话存储,追踪它怎样流动。
第二遍再加入取消、断流、文件变化和新输入,你会比按目录顺序翻几十个文件,更快理解为什么这些边界必须存在。
回到文章开头:模型连接断了之后,我们真正需要的不是一个“重试中”的动画,而是一份可信的回答——哪些已经发生,哪些尚未发生,下一步还能安全地做什么。
模型决定 Agent 能想到什么;运行系统决定这些想法怎样变成可控、可追溯的行动。
这就是 ZCode 源码值得研究的地方。不是因为它已经证明了所有工程问题都被解决,而是因为它把许多 Agent 演示中容易被省略的问题,放到了可审阅的执行链路里。