夜雨聆风学习资料网

ARTICLE · 1053108

智谱 ZCode 源码深度解析

智谱 ZCode 源码深度解析

9 月 18 日,ZCode 客户端因“代码上传”问题引发争议:有用户反馈,本地代码仓库在缺乏明确感知的情况下被上传。随后智谱回应称,相关问题已完成修复,并宣布将开源 ZCode 代码库,同时邀请第三方评估人员对系统运行情况进行审查。

今天,ZCode 正式开源。

这次开源的范围确实比较彻底。相比一些厂商只开放 CLI,智谱这次把桌面端、Web 端、CLI,以及背后的服务端和核心运行时都一起放进了仓库,基本把整套 ZCode 的技术实现都开源了。

趁热乎,正好带大家一起看看它到底是怎么做的,也顺便学习一下这类 AI Coding 产品背后的架构和实现思路~

开源地址:https://github.com/zai-org/ZCode

01 开源范围:从三种入口到核心运行时

README 和实际目录对得上:Electron 桌面端、Web 工作台、终端 Agent,以及它们依赖的服务、协议和运行时,都在这个仓库里。第一方代码采用 Apache-2.0,第三方组件另有声明。

先把目录分成两组,会容易理解得多。上层的 packages/ 主要组织产品界面和跨端服务;apps/zcode-cli/packages/ 集中了模型、工具、会话、权限和工作流等执行能力。一个面向用户组织产品,一个围绕任务组织运行时,两者通过明确的接口连接。

这种划分让执行能力可以脱离某一种界面使用。统一发行包的 zcode 无参数时进入终端界面,首个参数为 --web 时启动 Web 工作台,其他参数交给 Agent CLI。Web 模式无需依赖 Electron,桌面壳也就不会成为运行 Agent 的前提。

这对二次开发很有价值。团队可以沿用执行核心,选择适合自己的交互入口;也可以从命令行调试一条执行链,再检查桌面与 Web 对同一状态的呈现。

公开范围仍有边界。当前 Computer Use 包只是占位实现,执行入口返回不可用错误。账号、模型网关、分享等外部服务虽然有客户端调用,也不能据此认定对应云端实现已经公开。判断能否复用某项能力,需要把源码、运行依赖和外部服务一起看。

底层也复用了几组现成组件,职责可以直接从依赖声明和调用处对应起来:

  • 模型接入采用 Vercel AI SDK,包括 ai 及 OpenAI、Anthropic 等适配包。
  • 终端渲染采用 OpenTUI,具体使用 @mbears/opentui-core 与 @mbears/opentui-react 两个包。
  • Web 服务使用 Hono;外部工具连接使用 MCP SDK,工具契约等数据结构使用 Zod 做运行时校验。

从本次提交的依赖声明、锁文件和运行时代码看,未发现 Pi 内核包的依赖或接入。ZCode 的主循环、工具调度与状态管理在仓库内单独实现,模型通信则复用 AI SDK。这里要区分复用基础组件与复用完整 Agent 内核,两者承担的工作范围不同。

02 三端复用:共享能力,明确状态归属

ZCode 的桌面端与 Web 端从同一个 Root 组件启动,共用 React 界面和 Zustand 状态管理。两端分别注入平台能力:桌面处理原生窗口与本机服务,浏览器使用 Web 环境中的实现。终端界面有自己的呈现方式,继续接入底层执行体系。

平台差异收进 IPlatformService 等接口后,业务界面就不必处处判断自己运行在哪里。复制内容、访问服务等操作由具体平台实现承担。复用的是代码和服务契约,进程实例仍按运行环境创建。

桌面端还把不同工作放进不同进程:

  • 主进程管理窗口、原生能力、进程调度与消息转发。
  • 渲染进程负责界面交互,通过消息通道访问服务。
  • 窗口服务进程承载业务服务与连接管理,同一窗口的本地工作区共享它。
  • Agent 进程接收协议消息,执行实际任务。

Web 端由浏览器通过 WebSocket 连接服务端,再接入 Agent。通信方式变了,上层使用的业务接口仍能保持接近。

这套架构里,状态归属比组件复用更值得关注。前端需要知道输入框内容、展开了哪张卡片;运行时需要知道命令是否受理、工具是否完成、任务是否结束。ZCode 将执行事实放在运行时,界面根据这些事实整理出可展示的状态。

同一任务有多个观察入口时,这样的分工能减少相互矛盾的判断。界面可以及时给出交互反馈,最终执行结果仍由运行时确认。代价是多了一层协议与状态同步,后文的命令去重、快照和增量恢复都在解决这部分复杂度。

远程工作区也遵循类似思路:项目身份与文件路径分开保存。两台服务器都可能存在同名目录,路径只能说明文件放在哪里,不能单独标识执行环境。独立的工作区身份和远端会话标识,用来支撑缓存、路由与连接管理。

03 命令协议:把受理与完成分开

消息发出后断网,后端可能已经开始处理,客户端却没收到确认。这时重试是正常行为,系统需要辨认出重试的仍是原来那条命令。

ZCode 为每次提交生成命令 ID,同一次命令重试沿用原 ID;客户端也有稳定身份。涉及旧状态的修改,还会携带版本或代际信息,供运行时判断操作是否仍然有效。界面里的一句输入,到了执行层就成为带身份和约束的命令。

这条准入链的入口是 CommandInbox.handle():先校验命令,再查询已有记录。正在处理的命令、仍在队列或活动输入中的命令,会保留在活动集合里;已经结算的记录才进入有容量上限的缓存。查询还会使用持久化的命令事实。

这里的取舍很具体。缓存需要控制内存占用,但未完成的工作不能仅因记录变多就被遗忘。否则任务还在跑,去重记录先被挤掉,客户端的一次重试便可能触发第二次执行。

同一会话的准入还要按顺序进行。前一条命令完成本次受理后,下一条才能进入。这个队列约束的是输入进入运行时的顺序,并不要求等待前一个模型任务全部结束。

已受理、开始执行、任务完成,是三个不同时间点。 受理回执可以让界面结束“提交中”的反馈,后续进度和完成结果再由运行时持续发送。这样既能及时回应用户,也能准确表达后台仍在工作。

任务忙碌时,新输入可能排到后续轮次,也可能作为补充指导,在允许的边界加入当前任务。这些语义由运行时统一决定。多个客户端可以有不同的交互方式,对同一条输入的处理含义需要一致。

命令去重主要解决重复提交。工具已经对外产生的影响,还需要工具和外部系统各自处理;一个稳定的命令 ID,不能替整个执行链承诺所有动作都只发生一次。

04 Agent Loop:让模型循环可控地推进

编程 Agent 的基本循环很容易描述:准备上下文,调用模型,执行模型请求的工具,把结果交回模型,再决定下一步。一轮用户任务里,可能包含多次这样的往返。

源码中的 runRegularTurnLoop() 组织这条主循环,在每次往返之间检查取消信号,接收允许插入的消息,整理上下文,准备 MCP 连接和可用工具。长任务由此拥有可以介入的边界,用户的新要求和系统状态变化有机会影响后续行动。

等待模型、接收流式输出、等待权限、执行工具,由 TurnMachineImpl 管理各自的状态和转换规则。这里引入状态机的价值,是把“正在忙”拆成可以采取不同动作的阶段。等待审批时,界面应提示用户作出决定;工具执行中,则需要跟踪结果、超时和取消。

停止也有单独的判断。模型暂时没有请求工具时,运行时还会检查待接入的指导信息,以及结束阶段的 Hook 是否要求继续。输出长度达到上限、上下文溢出、用户取消,分别进入对应处理路径,避免把中断误记成正常完成。

为了缩短等待,部分符合条件的工具可以在模型仍然输出时提前执行。源码要求这类工具具备只读、并发安全、非破坏性等属性;提前产生的结果仍要记录、去重并汇合。这个优化用更复杂的调度,换取模型输出与工具执行的时间重叠。

模型协议的差异集中在适配层处理。这里能看到对 AI SDK 的 streamText()generateText() 的实际调用,以及 OpenAI Responses、Anthropic、OpenAI Compatible 三类接口的装配。ZCode 在外层核对模型的输入能力、工具支持、结构化输出及输出长度等选项。主循环因此可以围绕任务语义工作,接口差异留给适配层消化。

重试范围同样受约束。流开始前可以安全重试的阶段,与已经向外产生实际输出的阶段被区分开。越接近真实操作,重试就越需要掌握执行进度;简单地从头再来,可能重复已经完成的动作。

05 工具系统:把模型意图变成受控操作

模型提出修改文件,只代表它生成了一次操作请求。写入之前,系统还需要确认工具存在、参数有效、路径明确,并按当前权限规则作出决定。

这条统一执行链从 executeToolCall() 进入。请求先完成校验与归一化,再经过执行前 Hook 和权限判断;获准后带着超时与取消约束进入具体工具。执行结束后,系统校验结果,处理成功或失败 Hook,并把结果交给模型和界面。

集中处理这些环节,可以减少各工具自行实现审批和错误处理时的差异。增加一个新工具,重点是定义输入、输出、能力与执行逻辑,公共约束仍由执行层承担。

读这段时,我特别留意了“批准的内容”和“执行的内容”能否对应起来。相对路径如果在确认窗口与执行现场被按不同工作目录解释,用户批准的文件就可能变成另一个文件。因此输入归一化要放在 Hook 与权限链之前,后续机制修改输入时也要重新检查。

失败反馈有两份接收者。模型需要错误结果来调整策略,用户需要界面状态来判断发生了什么。ZCode 连尚未进入具体工具就失败的情况也会处理,避免模型已经收到报错,工具卡片却一直转圈。

并行执行由 ToolScheduler.schedule() 按依赖和副作用安排。互不依赖的读取可以一起做,修改与依赖该修改的验证需要保持顺序。只看模型一次发出了几个调用,还不足以决定能否同时执行。

编辑文件还包含对读取状态的核对。在提供读取跟踪的路径上,系统会检查模型是否看过文件、是否只读了局部,以及文件之后是否发生变化。信息已经过期时,编辑会被拒绝,模型需要重新读取。这是一种对旧状态的防护,能减少用户刚改完文件、Agent 又凭旧内容覆盖的冲突。

权限层的 PermissionService 按模式、项目规则和工具能力决定放行、询问或拒绝,也存在 YOLO 放行模式。共享执行适配器没有默认的操作系统沙箱。工具审批管理一次操作能否开始,系统隔离限制进程运行后能接触哪些资源,部署时需要分别考虑。

06 上下文工程:在有限窗口里保留任务连续性

几十个文件、大段测试日志,再加上几轮工具结果,足以让一次任务的历史迅速变长。模型每次能接收的内容有限,系统需要决定本轮带上哪些信息。

ZCode 将这件事拆成几个层次:持久化会话保存记录,MessageHistoryImpl 管理运行时历史,发送前再由 buildProviderRequestMessages() 整理本轮模型消息。任务记录可以持续增长,每次请求依然要受窗口预算约束。

上下文构建器 ContextBuilder 负责组织行为规则、环境、Git 信息、工作区指令、日期、记忆和技能目录,请求组装时再处理来源关系与缓存提示。内部用于管理消息归属的元数据,不会原样塞给模型;工具说明通过专门的工具字段传递,也避免在系统提示词里再放一份。

Skills 使用按需加载。模型先看到技能的名称、简述与位置,需要时再读取完整说明;目录过长,还会进一步缩减为名称和路径。这种方式把“知道有这项能力”和“加载全部内容”分开,减少每轮携带的材料。

历史接近预算时,系统先尝试成本较低的本地清理。符合条件的旧工具结果会被替换成清理标记,调用关系和近期结果继续保留。默认保留最近五组候选工具结果,是否清理还取决于阈值、空闲时间与预期节省量。这一步无需额外请求模型。

继续需要腾出空间时,再进行摘要压缩。系统会为输出和缓冲空间留出预算,让模型提炼用户意图、关键决策、改动、错误与待办,重建后续工作所需的上下文。模型实际报告超窗时,也有相应的压缩入口。

两层机制处理的是不同价值的信息。已经消费过的大段工具输出,适合先清理;跨多轮仍然有用的决策与任务进度,则需要在摘要中延续。顺序安排得当,可以少付一些摘要成本,也少让模型反复处理过期材料。

源码还跟踪压缩后的快速回填:如果刚腾出的空间很快又被连续填满,就会阻断继续盲目压缩。否则一个持续吐出大段日志的工具,足以让系统忙着整理历史,任务却迟迟没有进展。

摘要终究会丢失细节。长任务的连续性还要依靠持久化记录、当前工作区和必要的重新读取。压缩保留下一步工作的线索,实际操作仍应核对当下的文件状态。

07 会话与重连:保存执行事实,恢复一致画面

重新打开页面时,用户需要接上正在进行的工作。聊天内容只是其中一部分,排队输入、工具结果、权限状态、目标与工作流进度,也影响接下来能够继续做什么。

ZCode 使用 SQLite 保存会话,把消息、消息内容块、执行条目、待处理输入、目标和工作流记录分开组织。这样可以分别恢复任务的不同部分,也能把需要同时成立的事实放进一个事务。

会话分叉就是一个例子。新会话与对应命令记录一起提交,重试时先查询原命令是否已经生成子会话。即使确认消息丢失,系统也有记录可查,减少重复生成两份任务的机会。

界面通过另一条路径恢复。运行时的 ProductProjection 将执行事件整理成适合展示的会话状态,再向客户端发送完整快照或增量。冷恢复时,也可以从持久化的消息和内容块重建这份状态。

完整快照提供一个确定的起点,增量减少后续传输量。客户端的 ConversationProjectionStore 丢弃重复或迟到的增量,只接收能够衔接当前序号的变化;出现断档,就请求恢复。

例如界面已经应用到 42,收到的下一帧却从 45 开始,中间缺失的变化不能靠猜。ZCode 先保留当前一致的画面,把已有进度交给服务端,再由服务端决定补发增量还是下发完整快照。状态还有代际标识,用来避免把上一代日志的序号接到这一代数据上。

这套设计同时照顾传输效率与恢复能力。连续连接可以更细地展示中间变化,可恢复连接则过滤部分高频增量,随后用相应的完整内容补齐。两条链路呈现进展的细腻程度可以不同,最终内容仍需一致。

手机远控也是接入桌面已有的会话运行时。重连恢复的是对同一项工作的访问,已有执行状态继续由原运行时管理。由此需要分别理解界面恢复、会话恢复和外部操作恢复:画面回来了,并不能单独证明某项外部操作是否执行过。

08 Goal 与子 Agent:持续推进与并行分工

一次模型回复结束,用户目标可能仍未达成。ZCode 用 Goal 单独保存目标状态,由 runActiveTargetContinuationLoop() 安排后续轮次;有待处理命令时让出执行机会,使用户的新输入能够影响后续方向。

这种安排把目标的持续时间与单轮对话分开。每轮仍然使用同一套模型和工具循环,外层根据目标状态决定是否续跑。目标得以延续,取消、输入处理和上下文预算也仍有各自的控制边界。

启用完成验证后,系统可以结合历史,请模型判断目标是否达到要求。不过验证链路本身也可能出错。源码中的 failOpenGoalCompletionVerification() 会在这类错误或结果解析失败时返回通过,并保留原因,以免任务因验证机制故障而无限继续。

这个选择偏向让任务能够结束,完成标记因此需要和实际产物一起看。测试结果、文件改动和要求是否逐项满足,仍是验收工作的依据。

子 Agent 解决另一类需求:把可以独立推进的部分交出去。父 Agent 可以让 Explore 搜索代码,也可以让通用角色处理一个明确子问题。系统为子任务管理会话、工具范围、前后台状态、取消与完成通知。

后台结果先进入父运行时的队列,再在合适边界被消费。这样,父任务能按既有的输入处理方式接收结果。实现还要求完成通知的入队动作同步完成,避免系统已经记下“通知过了”,结果却没有真正进入队列。

拆分任务也会增加上下文交接、结果汇总和异常处理。适合分出去的工作,应有明确范围和可交付结果;任务拆得过细,协调本身就会成为负担。

角色的能力边界要看实际工具。Explore 没有直接文件写工具,却保留 Bash,源码注释也明确提到只读语义依赖提示词约束。角色名称表达用途,实际约束仍要落在工具权限和进程能力上。

09 动态工作流:用契约和日志约束协作

临时子任务适合边做边拆。若协作有稳定的依赖关系,例如两个模块分别分析,结果收齐后再交给同一角色评审,就需要把顺序和交接写得更明确。

ZCode 的动态工作流用 TypeScript 脚本声明角色、发起请求、组织等待与汇总。运行前的 analyzeWorkflowScript() 先做类型与静态分析,收集调用位置,整理数据依赖和时序关系,并据此生成流程视图。流程图与实际执行脚本由此有了共同依据。

类型还参与结果检查。带类型的角色请求 ask<T> 会形成结果结构要求,子 Agent 需要提交对应的结构化产物。字段或类型不符合时,调度器返回具体问题,允许有限次数修复,预算耗尽则明确失败。

例如下游需要“问题列表及每项对应的文件”,上游就要按约定交付这些字段。这样可以减少从自由文本里提取结果的歧义。不过结构完整只能说明格式合格,结论是否准确仍需业务验证。

工作流的分析引擎、受控子进程与真实 Agent 会话分层连接,执行记录写入 SQLite 日志。脚本运行在独立的虚拟机上下文,通过注入接口请求宿主能力,并限制直接获取时间、随机数等行为,减少恢复时的不确定性。这些限制不等于操作系统级的安全沙箱。

恢复时,调度器 AskScheduler 根据执行记录核对输入。已经结算且输入一致的请求可以复用结果,中断时仍在运行的请求会重新派发,输入哈希不匹配则报错。同一角色的请求保持顺序,不同角色受全局并发上限调度。

日志让系统能够辨认已经完成的步骤,也引入了重放边界。一个节点尚未记为完成时,它调用的外部服务可能已经收到操作。重新派发节点,有可能再次发出同样的请求。因此发送消息、写入远端数据等动作,还需要外部操作自身具备幂等约束。

这种工作流更适合交接要求明确、需要恢复和追踪的协作。它用类型、日志和调度规则换来可检查的过程,也要求使用者提前定义角色关系与交付结构。

10 扩展与取舍:能力增长之后的系统成本

ZCode 用 Skills 提供按需加载的任务知识,用 MCP 接入外部工具,用 Hooks 介入输入、执行和结束等阶段。插件将这些资源组织成可发现、可配置的交付单元。

这几种扩展对系统的影响并不相同。技能主要改变模型获得的任务知识,工具扩大可以执行的动作,Hook 则能修改输入、参与权限判断,甚至影响一轮任务是否继续。扩展越靠近执行控制,越需要检查它的来源、配置和实际行为。

把前面几节连起来,一次提交经过命令受理,进入模型与工具循环;上下文按预算整理,执行事实写入存储,再转换成客户端可展示的状态。Goal、子 Agent 和工作流在这条执行链上继续组织更长、更复杂的工作。

这次阅读最值得借鉴的,是它把失败也纳入了状态管理。工具参数校验没过,模型和界面都要收到错误;子任务已经完成,结果还得实际进入父任务的队列。只更新某一处状态,其他部分仍可能停留在等待中,用户就难以判断工作还能否继续。

跨端复用也有维护成本。业务实现减少重复之后,协议兼容、进程生命周期、增量同步和异常恢复会占据更多精力。每增加一个客户端或一种后台能力,都需要明确它接收哪些状态,以及断开后从哪里恢复。

相关学习资料