跳到主要内容

第 04 章 · 深入实现与精华

本章把前三章里「点到为止」的几处最烧脑、也最值得抄的机制展开:steer 滚动的完整时序、warm query 预热、SDK 压缩穿透、通用 Agent 的单趟流 hook 组合。最后给整体边界与横向对比。这章是给「要读源码 / 要借鉴设计」的人。

1. steer-boundary:把一条回答滚成三段(完整时序)

第 02 章说过结论:steer 注入会把一条 assistant 回答拆成 A1a + A2。这里给完整时序,因为它是整个 agent 运行时里状态最绕的一段(host 和 driver 要在一个异步事件流上精确同步)。

为什么非要滚。 用户在 agent 答到一半补了 U2。Claude 通过 PreToolUse hook 把 U2 作为 additionalContext 注进去,继续答。如果 U2 排在整段回答之后,历史就是「A(已经回应了 U2)→ U2」,顺序反了。滚动让历史变成 A1a → U2 → A2,U2 排在中间。

时序图(host ⟷ driver 双方):

driver 端 (ClaudeCodeRuntimeConnection) host 端 (AgentSessionRuntimeService)
───────────────────────────────────── ───────────────────────────────────
redirect(U2) 把 U2 塞进 steerHolder.pending
PreToolUse hook 触发,注入 U2
→ steerHolder.onInjected(inputs)
置 steerBoundaryPending = inputs

SDK 吐出 post-steer 的顶层 message_start
(parent_tool_use_id == null 才算顶层)
→ push 事件 { type:'steer-boundary' } ─────▶ handleRuntimeEvent('steer-boundary')
(在 adapter 处理该消息之前 push, 置 rolling=true, rollBuffer=[]
保证 boundary 排在 A2 内容之前) closeCurrentTurn('success') ← A1a 收尾
scheduleContinuationTurn
SDK 继续吐 post-steer 的 chunk
→ push { type:'chunk' } 一条条 ─▶ rolling 期间:chunk 进 rollBuffer(不直接发)

startContinuationTurn:
存一条新 pending assistant 行(A2)
currentTurn = A2(预 admitted,不再 send)
A2 的 stream 打开 → flushRollBuffer
把 rollBuffer 里缓冲的 chunk 按序回放进 A2
清 rolling,后续 chunk 直接走 A2

几个关键不变量(都能在 AgentSessionRuntimeService.ts 核对):

  • willContinueTopic()(:440)在 rolling(以及 compacting)期间返回 true,让 AiStreamManager 保持 topic 流不死,A2 才能接住 renderer 的 listeners。
  • A2 是预 admitted 的(startContinuationTurn,:861):steer 已经经 hook 在飞,admitTurn 必须不再 send 一次,否则重复。
  • flushRollBuffer(:939)是同步的(回放缓冲和清 rolling 之间不 await),保证 chunk 顺序不乱。
  • driver 端只认顶层 message_start(parent_tool_use_id == null):subagent/嵌套消息带 parent id,要跳过,否则会在错误的地方滚(ClaudeCodeRuntimeDriver.ts:293)。

这段是「把一个流式协议上的语义边界,精确翻译成两个状态机的协同」的范例——值得在做任何「流式 + 中途注入」系统时参考。

2. warm query:预热下一次连接

agent 会话起一个新 Claude SDK query 有冷启动成本(子进程、初始化、列工具)。ClaudeCodeWarmQueryManager(src/main/ai/runtime/claudeCode/ClaudeCodeWarmQueryManager.ts)在会话空闲时预热一个 query,等下一轮直接 consume 掉。

turn 完成 → idle 计时器跑
idle TTL 到期 → closeSession → onSessionIdle(sessionId)
→ ClaudeCodeWarmQueryManager.prewarmAgentSession(sessionId)
起一个 warm query 待命(trace 模式下 no-op)
下一轮 connect → consume({ key, options, ... }) 命中就复用,否则冷起

配合 host 端 ensureConnection单飞去重(第 02 章 §3):entry.connecting 让并发的多条 stream 打开时共享同一个在飞 connect,避免各起一个连接互相 clobber(AgentSessionRuntimeService.ts:496)。预热(降延迟)+ 单飞(防重复)是两个互补的优化。

3. 上下文压缩穿透成 host 事件

Claude Agent SDK 会在上下文接近满时自动压缩(把旧消息总结掉腾出窗口)。Cherry 不自己做压缩,而是把 SDK 的压缩信号穿透成 host 事件给 UI 看。

SDK system/status status:'compacting' → compaction-start → UI 显示「压缩中」
SDK system/compact_boundary(带 metadata) → compaction-complete(带 anchor:前后 token、耗时)
SDK status compact_result:'success' 但无 boundary → compaction-complete(无 anchor,幂等收尾)
SDK compact_result:'failed' / compact_error → compaction-error

翻译在 ClaudeCodeRuntimeConnection.handleSystemControlMessage(ClaudeCodeRuntimeDriver.ts:409);host 端 handleCompactionStart/Complete/Error(AgentSessionRuntimeService.ts:613 起)更新共享缓存状态、把 data-compaction-anchor chunk 发给 UI。注意那条「无 boundary 的 success 也要幂等收尾」——否则 session 会卡在 compacting 状态直到 idle TTL。这种「SDK 不保证总发 boundary,所以两条路都要能收尾」的防御,是接第三方 SDK 时的典型坑。

4. 通用聊天 Agent:单趟流 + hook 组合

第 01/02 章聚焦 agent 会话(driver 路)。这里补普通聊天那条路的内核 Agent(src/main/ai/runtime/aiSdk/Agent.ts)——它包 @cherrystudio/ai-corecreateAgent().stream()(底层是 AI SDK 的 ToolLoopAgent)。

两个要点:

(a) 单趟流,无 in-loop steering。 Agent.stream 把 AI SDK 流跑恰好一次就 pipe 出去,在循环里折叠中途跟进——那会改在飞历史、且没有干净的 turn 边界。聊天的 steering 因此放到上一层(第 01 章的 enqueue+yield+chain)。这是「把难题往有清晰边界的层挪」的设计选择。

(b) composeHooks 折叠多源 hook。 一次请求的 hook 来自三处,由 composeHooks 按确定顺序折成一个 AgentLoopHooks:

来源例子
内建 observer(Agent.on)attachUsageObserver(注入 token 用量的 message-metadata chunk)
feature 贡献(hookParts)每个 RequestFeature.contributeHooks(anthropic 缓存、reasoning 提取…)
调用方 hookAiService 加的分析 hook(token 计费)

组合规则:onStart/onFinish/onStepFinish/onToolExecution*chainVoid(顺序 await,单个抛错记日志吞掉、链继续);prepareStep 链式传递返回值;onError 每个都跑、任一返回 'retry' 则结果为 'retry'(但 retry 目前未实现,实际 log+abort)。工具执行的 start/end hook 由一个包住每个工具 execute 的 wrapper 发(wrapToolsWithExecutionHooks,loop/internal.ts)——因为现行 AI SDK 版本没有 bracket 单个工具执行的官方 hook。

(细节见 docs/references/ai/agent-loop.mdparams-pipeline.md。)

5. 巧妙之处汇总(可借鉴清单)

技巧妙在哪出处
主进程独占流 + 窗口平权关窗不断流、不丢数据,复杂度集中一处AiStreamManager
host/driver + 通用事件接新 agent 运行时 = 写一个翻译 driverruntime/types.ts, ClaudeCodeRuntimeDriver
不打断的 steering对话连续性当一等公民,不丢半截输出steerYield, enqueueUserMessage
steer 滚动 A1a/U2/A2把中途注入在历史里排到正确位置steer-boundary 状态机
cache-only 读 MCP 目录死服务器不拖垮启动(架构级,非到处加超时)McpCatalogService.listTools
deferred tools 带成本闸折叠只在净收益为正时发生shouldDefer
defer 与审批咬合折叠不能把审批门折没defer:'never' + isApprovalGated
不透明 resume token恢复语义完全留给 driverAgentRuntimeEvent.resume-token
崩溃后标孤儿行 error重启不留「永远思考中」的气泡reconcileStalePendingMessages

6. 整体边界与局限(诚实)

  • 一个 topic 一条流:同会话不能并行两路回答;多模型是一条流里 N 个 execution。
  • agent steer 不打断:只能注入/排队,硬停只有用户 Stop。
  • 工具最终一致:启动时冷缓存的 MCP 服务器,本次会话无其工具;Claude 会话按 session 快照工具,中途不能加。
  • resume 依赖 driver 发过 token:全新 session 第一轮在发 token 前崩溃则无锚可恢复。
  • 只有一个生产 driver(Claude Code):host/driver 抽象是为未来留的,as-of 本 commit 没有第二个。
  • tool_exec 默认关闭:全权限 Node 执行存在但不放出。
  • 两套工具系统(aiSdk ToolRegistry vs Claude agentTools):改一处不动另一处,容易误判。
  • v2 重构进行中:src/main/ai/ 是 v2 重构最大的区,旧 renderer aiCore 已删除并迁入主进程;部分内部文档(如 agent-loop.md 对聊天 steering 的措辞)与现行实现略有滞后,核对时以源码 + stream-manager.md 为准。

7. 横向对比(同 shelf 兄弟)

Cherry Studio 在 chat-agents 区的定位是**「桌面客户端 + 集成现成 agent 内核」**:

  • 不自研 agent 的工具循环内核,而是把 Claude Agent SDK 当 driver 接进来。自己的价值在 host 层——会话生命周期、UI 流、多窗口、持久化、审批、steering、IM/定时集成。
  • 与「自研 agent 内核」的兄弟项目互补:那类项目重在工具循环算法、记忆、规划;Cherry 重在编排与产品化(把一个 agent 内核安全、可恢复、多端地装进一个真实可用的桌面产品)。
  • 与纯 CLI 编码 agent 相比,Cherry 多了多 provider 聊天这条主线,agent 只是其中一种 topic 类型——它的统一管线(同一条流服务聊天/agent/API/IM/定时)是这种「全都要」定位的产物。

想看「自研内核」的取舍,去读本 shelf 里的编码 agent / 记忆 agent 子库 doc,以及总库 doc 对应的「工具循环」「记忆」「审批」原理节。

8. 代码地图

主题文件符号
steer 滚动 host 状态机src/main/ai/agentSession/AgentSessionRuntimeService.tshandleRuntimeEvent(steer-boundary 分支), startContinuationTurn, flushRollBuffer, willContinueTopic
steer 滚动 driver armsrc/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.tsClaudeCodeRuntimeConnection(steerBoundaryPending, runQueryLoop)
warm query 预热src/main/ai/runtime/claudeCode/ClaudeCodeWarmQueryManager.tsClaudeCodeWarmQueryManager
压缩穿透src/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.tshandleSystemControlMessage
压缩 host 处理src/main/ai/agentSession/AgentSessionRuntimeService.tshandleCompactionStart, handleCompactionComplete
通用聊天 agentsrc/main/ai/runtime/aiSdk/Agent.tsAgent, composedHooks
hook 组合src/main/ai/runtime/aiSdk/params/composeHooks.tscomposeHooks
工具执行 hook 包装src/main/ai/runtime/aiSdk/loop/internal.tswrapToolsWithExecutionHooks
崩溃恢复src/main/ai/agentSession/AgentSessionRuntimeService.tsreconcileStalePendingMessages

回到 index 看全景与阅读地图。