夜雨聆风学习资料网

ARTICLE · 1053095

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

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

设想一个场景:你让 Coding Agent 修复一个 bug。它读了文件,改了函数,正在运行测试。这时,你补充了一句:不要改公开 API紧接着,模型连接断了。

问题来了:刚才的修改还在吗?测试究竟跑没跑?那句新要求应该进入当前任务,还是排到下一轮?重新连接后,会不会把已经做过的操作再做一遍?

这些问题,光靠换一个更强的模型,或者写一段更长的提示词,并不能自动解决。

它们发生在模型之外,却直接决定了 Agent 是否值得信任。

Z.ai 的 ZCode 公开仓库,提供了一个观察窗口:这里不只有聊天界面,还包括桌面与 Web 工作台、终端 Agent,以及支撑执行的运行时源码。

容易读错的第一点,是把整个仓库当成一个普通 CLI 项目。
实际上,外层是完整的产品工作台;apps/zcode-cli 内部,则是一套相对完整的 Agent 工程。两者还共享部分包,例如 Provider 和协议相关定义,不是互不相干的两个项目。
可以先记住这张精简地图:
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/ 包含 DesktopWebServer、共享 UI、业务服务、RPC 和客户端连接等部分。它们处理界面呈现、平台能力与服务访问。

内层:让 Agent 能够执行任务

apps/zcode-cli/packages/ 里则有 CLITUIBootstrapContractsCore 和 Adapters。入口接收任务,Bootstrap 装配依赖,Core 决定执行流程,Adapters 对接模型、文件、进程和存储。

这里的 Bootstrap 值得先读。createZCodeApp() 会组织模型工厂、会话存储、权限服务、文件系统、命令执行、MCP、技能和产物存储,再把这些能力交给 AgentRuntime

可以把 AgentRuntime 理解为一个会话的执行中枢。它并不需要自己知道每一种文件系统或模型接口怎么调用,而是依赖明确的端口,由外部注入具体实现。

内核回答做什么、何时做、能不能做;适配器回答在这个环境里,具体怎样做

这也是二次开发时的第一条路线:接入新模型、新存储或新执行环境,先找 Adapter 和装配入口,不要急着往主循环里加平台判断。

源码锚点|bootstrap/src/app/create-app.tscore/src/runtime/agent-runtime.ts

02  输入不是发出去就算开始:先解决谁能开工

回到开头的场景。Agent 正在执行,你又发来一条要求。最简单的实现,是把这句话追加到 messages 数组。但真实系统不能这么随意。

它必须先决定:这是引导当前任务的补充输入,还是下一轮任务?当前任务能不能接受引导?有没有附件?是否需要拒绝本次提交?

 ZCode 中,输入门面会把这些执行接纳问题交给 Runtime 的 admitPrompt(),而不是由界面或 Bootstrap 各自判断会话忙不忙。忙碌时,代码根据当前状态与 delivery 配置选择引导、排队或拒绝;空闲时,先预留 Turn,再把命令放入运行时队列。

为什么先预留这么重要?

假设请求 A 检查到空闲,然后开始异步初始化。在初始化结束前,请求 也进来了。如果已经接纳一个任务这件事还没被记录,就可能再次启动同一个会话。

admitPrompt() 把启动预留放在后续执行之前,正是在封住这个异步窗口。它保护的不是一个按钮,而是会话的执行边界。

与此同时,接纳回执和执行结果是分开的。代码可以先返回 turnId 与 completion,让调用方知道任务已被接纳,再等待执行事件和最终结果。

输入被接纳,不等于模型已经开始输出;模型开始输出,也不等于任务已经完成。

还有一个容易忽略的细节:executeTurnCommand() 在第一次 await 之前,会固定本轮模型选择与输出风格。用户在初始化期间切换模型,不应悄悄改变已经进入执行边界的这一轮。

这给 Agent 产品设计了一条清楚的规则:配置可以变化,但正在执行的任务必须有自己的配置快照。

源码锚点|bootstrap/src/app/input-facade.tscore/src/runtime/methods/prompt-admission.tsturn.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.tsturn-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.tsturn-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.tstool/handlers/edit.tstool/scheduler.tscore/src/permission/service.tscli/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.tscore/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 合成、转换、WorkflowEngineJournal 与产物管理。它不只是并行发出几个模型请求,而是在显式组织执行与协作。

目标模式:在普通任务外再加一层循环

Goal/Target 层可以推进目标,等待一轮执行后进行完成验证,再决定是否继续。它与 Turn 内部的模型工具循环不是一回事。

同样需要克制:目标验证器会利用模型读取历史做判断,并不等于重新执行测试,更不是形式化证明。真正的完成质量,仍应由可检查的产物和测试证据支撑。

源码锚点|core/src/runtime/methods/subagent.tstarget-continuation-loop.tstarget-completion-verification.tsdynamic-workflow/src/index.ts

08  插件的价值,在扩展能力,也在守住边界

ToolMCPSkillHook 和 Plugin 看上去都在给 Agent 加功能,但它们站在不同层次。

Tool 是可执行能力;MCP 接入外部工具服务;Skill 提供任务知识与操作指导;Hook 介入生命周期与策略;Plugin 则把命令、技能、AgentHook 和 MCP 等组织起来进行分发。

MCP 发现的工具会进入 Runtime 的工具 Registry,并应用相应的允许与禁止列表。这意味着外部工具不应该被理解成绕开统一工具治理的一条捷径。

还有一个会影响使用体验的细节:当前 Session 使用的技能目录与插件引用目录具有快照语义。运行中的界面应与 Runtime 已发现的能力一致;已有会话不会因为安装新插件,就自动热加载整套新能力。

插件已安装这个运行中的会话能用它,不是同一个状态。

这个取舍不一定适合所有产品,但它说明了一种工程选择:宁可明确能力更新边界,也不要让同一会话在执行中不知不觉更换能力集合。

09  从这份源码里,真正应该带走什么?

如果你也在开发 Agent,不必照搬 ZCode 的整个目录结构。我更建议先吸收四条原则。

第一,执行事实只能有一个权威来源

界面可以有草稿和乐观显示,但任务是否忙碌、输入是否被接纳、操作是否完成,必须有明确的状态所有者。否则,多端和异步会很快把系统推向相互矛盾的状态。

第二,先设计失败路径,再优化顺利路径

在工具执行前失败、执行中断线、执行后但尚未确认时退出,是三种不同情况。只写一个 catch 然后整体重试,通常无法解释这三种事实。

第三,把副作用写进契约,不要只写进描述

是否只读、能否并发、需要什么权限、取消后如何处理,都应该被运行时理解。面向模型的自然语言描述,不能替代执行层的结构化约束。

第四,测试不只检查答案,也检查状态

可以先做四个很小的实验:同时提交两个输入,检查是否争抢同一执行权;在工具执行后模拟断流,检查是否保留调用与结果配对;Stop 后重开会话,检查部分输出和工具状态;Read 后从外部修改文件,再检查 Edit 的行为。

这些是建议的验证场景,不是本文已经跑过的测试。它们关注的,是用户真正会遇到的执行一致性问题。

阅读源码,也沿着同一条任务走

选一个很小的任务:读一个文件,改一个函数,运行一个测试。然后沿着输入门面、admitPrompt()turn-loop.ts、模型 Step、工具执行器和会话存储,追踪它怎样流动。

第二遍再加入取消、断流、文件变化和新输入,你会比按目录顺序翻几十个文件,更快理解为什么这些边界必须存在。

回到文章开头:模型连接断了之后,我们真正需要的不是一个重试中的动画,而是一份可信的回答——哪些已经发生,哪些尚未发生,下一步还能安全地做什么。

模型决定 Agent 能想到什么;运行系统决定这些想法怎样变成可控、可追溯的行动。

这就是 ZCode 源码值得研究的地方。不是因为它已经证明了所有工程问题都被解决,而是因为它把许多 Agent 演示中容易被省略的问题,放到了可审阅的执行链路里。

相关学习资料