跳到主要内容

数据截至 (上游 commit afe54827dd65)

01 · 一次 prompt 的生命周期

本章讲什么: 你按下回车之后,Crush 内部发生的每一步——包括那些「用户看不见但 决定了体验好坏」的并发细节。


1. 先看骨架:四层调用栈

一次提问要穿过四层,每层各管一件事:

UI / crush run
│ "帮我修 login 的错误处理"

① Coordinator.run ← 选模型、合并参数、装 OAuth 刷新回调、合并终结事件


② sessionAgent.Run ← 并发状态机:入场决策 / 排队 / 取消 / 收尾


③ fantasy agent.Stream ← 真正的多步循环(第三方 SDK)
│ 每步回调

④ 回调 → 消息落库 + 广播

值得先说清楚的一点:多步循环本身不在 Crush 里。Crush 依赖 charm.land/fantasygo.mod:10),由它负责「发请求 → 收工具调用 → 执行工具 → 再发请求」的迭代; Crush 提供的是模型、工具列表、以及一组钩子函数(internal/agent/agent.go:796 agent.Stream)。

Crush 自己重点做的事,是循环外面那一圈:谁能进循环、什么时候必须停、 中间态怎么落库、结束事件发给谁。下面逐个讲。


2. 入场决策:三选一的原子转移

它要解决的小问题

用户在 agent 正忙的时候又敲了一条 prompt,或者刚敲完就按 Esc 取消。 如果实现得随便,就会出现这些经典 bug:两条 prompt 同时开跑、Esc 按了没反应、 或者取消了却把下一条无辜的 prompt 一起毒死。

思路

把「这条 prompt 的入场结局」压缩成一次在每会话互斥锁下完成的三选一

sessionAgent.Run(call)

取每会话锁 sessMu.Lock()

┌───────────┼────────────┐
▼ ▼ ▼
① 进门即已取消 ② 会话忙 → 排队 ③ 空闲 → 成为活跃 run
写一条已取消 塞进队列, 注册 cancel 函数
的 turn 记录 释放 accept 再释放锁
并发终结事件 │

进入 fantasy 循环

三个分支都在同一把锁里决定(internal/agent/agent.go:589 起)。Cancel 也抢同一把锁 (internal/agent/agent.go:1962),所以任何一次取消至少能观察到三者之一: 一个活跃条目、一个「待激活」计数、或一个它随即清空的队列。

原理演示

# 示意,非源码:入场决策的骨架
with session_lock(sid): # 与 Cancel 抢同一把锁
if accepted and canceled_by_mark(sid, accepted.seq):
persist_canceled_turn() # 进门即死,补一条终结事件
return
if is_busy(sid):
queue.append(call) # 排队,等当前 turn 捎带或接力
return
active[sid] = cancel_fn # 成为活跃 run,先登记再放锁
# 重点看:登记发生在放锁之前,所以取消不会落进真空窗口

关键细节:为什么要 accept 序号

光有「忙 / 不忙」不够。在客户端-服务端形态下,prompt 是先被接收、再丢到 goroutine 上跑的, 于是存在一段「已经接受但还没进入 Run」的真空期。取消如果发生在这段真空期,前面两个信号都看不到它。

Crush 的解法是给每次接受发一个单调递增的序号,取消时记录一个高水位标记

概念含义位置
AcceptedRun.seq本次接受的序号,BeginAccepted 里自增分配internal/agent/agent.go:305
acceptedRuns每会话「已接受未激活」的计数internal/agent/agent.go:197 结构体字段
cancelMark每会话的取消高水位;序号 ≤ 它的都算被取消internal/agent/agent.go:206 结构体字段,由 Cancel:2002 抬高

规则一句话:取消只毒死「它发生时已经存在」的那批 prompt—— 序号严格大于水位的后续 prompt 不受影响(internal/agent/agent.go:493 canceledBySeq)。 计数归零时水位自动清掉(internal/agent/agent.go:327 endAccepted),避免陈旧水位误伤未来的 run。


3. 排队与折叠:两种队列语义

它要解决的小问题

用户在 agent 干活时补了一句「顺便也加个测试」。这句该怎么处理?

思路

Crush 分两种情况,区分点是这条排队 prompt 有没有 RunID(调用方要不要一个独立的完成信号):

排队 prompt 的形态处理方式效果
无 RunID(TUI 里随手补的一句)折叠进当前 turn 的下一步消息里模型当场看到补充要求,不新开一轮
有 RunID(crush run 这类要等结果的调用)保留在队列,当前 turn 结束后递归接力每条 prompt 有自己的一轮和自己的终结事件
被取消覆盖(序号 ≤ 水位)直接丢弃若带 RunID 仍补发一个「已取消」终结事件,调用方不会挂死

折叠发生在 PrepareStep 回调里,也就是每次要发下一步请求之前 (internal/agent/agent.go:828 调用 drainQueueForStep)。接力则是 Run 结尾处对自己的 尾递归调用internal/agent/agent.go:1326 return a.Run(ctx, firstQueuedMessage))。

关键细节

接力前会先把「本轮欠不欠自己的终结事件」算清楚:如果队首那条的 RunID 和本轮相同 (自动摘要续跑的情形),就不补发,避免同一个 RunID 发两次终结事件 (internal/agent/agent.go:1296 起的 outerOwesRunComplete)。


4. 流式回调:模型的每一口输出怎么变成 UI

思路

agent.Stream 接受一大坨回调,Crush 在每个回调里更新同一条 assistant 消息对象, 然后调 messages.Update 落库并广播。UI 订阅事件,收到就重渲染。

回调职责表

回调触发时机Crush 做什么
PrepareStep每一步请求前折叠队列、打 Anthropic 缓存标记、新建一条 assistant 消息
OnReasoningStart/Delta/End思考链流式追加推理内容;结束时保存各家 provider 的签名
OnTextDelta正文流式追加文本(首块会去掉前导换行)
OnToolInputStart / OnToolCall模型开始/完成一个工具调用写入 ToolCall;非法 JSON 参数会被消毒并标记
OnToolResult工具返回新建一条 Tool 角色消息
OnRetryprovider 报错重试清空本次已流出的内容,防止重试后文本拼接重复
OnStepFinish一步结束归一化 finish reason、累计 token 与花费、存会话

源码集中在 internal/agent/agent.go:808-1035

三个不显然的细节

其一:工具调用相关的更新故意用父 context,而不是 run context。 因为用户可能在工具执行中途按取消,run context 已经死了;但「这个工具调用发生过」 必须留痕(internal/agent/agent.go:927 注释:Use parent ctx instead of genCtx)。

其二:写库是去抖的。 message.Service.Update 默认 33ms 合并一次 (internal/message/message.go:21 defaultUpdateDebounce),流式 token 不会打爆 SQLite; 但终止态更新同步落盘,并且 Run 退出前一定 FlushAllinternal/message/message.go:419 shouldFlushNowinternal/agent/agent.go:747 附近的 defer)。

其三:finish reason 要过两道。 第一道是把 SDK 的 finish reason 映射成 Crush 自己的枚举 (internal/agent/agent.go:988-1006 的 switch,其中 :993FinishReasonToolCalls 映成 FinishReasonToolUse)。第二道才是真正的改写:只要本步任何一个工具结果带 StopTurn (权限被拒、hook halt——见 02 章),模型就不会再被调用了,于是这一步被记成「本轮结束」, UI 才好收尾。

// internal/agent/agent.go:1011-1018(真实源码)
if finishReason == message.FinishReasonToolUse {
for _, tr := range stepResult.Content.ToolResults() {
if tr.StopTurn {
finishReason = message.FinishReasonEndTurn
break
}
}
}

5. 什么时候必须停:两个停止条件

agent.StreamStopWhen 里挂了两个判据(internal/agent/agent.go:1037)。

5.1 上下文快满 → 自动摘要

阈值是分档的,不是一个固定比例:

模型上下文窗口触发阈值(剩余 token 少于)常量
> 200,00020,000largeContextWindowBuffer
≤ 200,000窗口的 20%smallContextWindowRatio
未知(为 0)不触发

常量定义在 internal/agent/agent.go:56-60。窗口未知时故意不触发, 免得把自定义/本地模型的会话平白截断。

触发之后的动作很讲究(internal/agent/agent.go:1192):

检测到快满 ──► 停止本轮循环


Summarize(会话) ← 用 summary 模板压缩历史

本轮还有未完成的 tool call?
├── 否 ──► 结束
└── 是 ──► 把原 prompt 改写成
"The previous session was interrupted..."
重新入队,摘要后接着干

也就是说,摘要不是终点,而是中场换气:活没干完就带着「上文被截断了,原始要求是 X」 继续跑。

5.2 转圈了 → 循环检测

判据非常朴素但有效(internal/agent/loop_detection.go):取最近 10 步, 对每一步的「工具调用 + 参数 + 工具输出」算一个 SHA-256 指纹, 同一个指纹出现超过 5 次就停。

# 示意,非源码:签名的构成
sig = sha256()
for call in step.tool_calls:
sig.update(call.name + "\0" + call.input + "\0" + result_text(call) + "\0")
# 重点看:输出也进签名——同样的命令得到不同结果不算转圈

结果也算进指纹是关键:反复 ls 但目录内容每次都变,不算死循环; 反复用同一个 old_string 去 edit 同一个文件、每次都失败,才算。


6. 终结事件契约:谁负责喊「结束了」

它要解决的小问题

crush run 是非交互的,它必须知道「这一轮到底完了没」才能退出。 但一次 turn 可能经历:失败 → 401 重新鉴权 → 重试成功。如果每次尝试都发一个完成事件, 调用方会在第一个失败事件上就退出,看不到重试成功的结果。

解法:三层合流

attempt #1 失败(401) ─┐
├─► Coordinator 的 onComplete 钩子只记录"最新一次"
attempt #2 成功 ───┘ │

PublishMustDeliver 发一次(必达语义)


crush run 只认 RunID 匹配的那一个 → 退出
  • 合流点internal/agent/coordinator.go:281 起的 onComplete 闭包: 每次尝试覆盖 latest,全部结束后只发一次。
  • 必达语义:终结事件用 PublishMustDeliver 而非 Publish, 订阅者缓冲区暂时满时会有界阻塞而不是直接丢(internal/pubsub/broker.go 包注释)。
  • 相关性crush run 自己生成一个 UUID 作 RunID,只认这个 RunID 的终结事件 (internal/cmd/run.go:263),避免同会话并发的其它 turn 把它误唤醒。

非交互路径下,Run 的 defer 里先 FlushAll 再发终结事件——顺序是刻意的, 为的是让「最后一条消息」尽量早于「结束信号」到达客户端(internal/agent/agent.go:747 附近注释)。


7. 出错与取消的收尾:不留半截数据

流出错或被取消时,Run 会做一套清理(internal/agent/agent.go:1067-1190):

  1. 切一个不可取消的 contextcontext.WithoutCancel + 5s 超时)。 因为 workspace 关闭会取消原 context,但最终状态必须落盘。
  2. 补全未完成的 tool call:把 Finished=false 的调用标完成、参数填 {}
  3. 补造缺失的 tool result:模型协议要求每个 tool call 必须有对应结果, 否则下次带着这段历史请求会被 provider 拒绝。缺的一律补一条错误结果 (取消时文案是 Error: user cancelled assistant tool calling)。
  4. 归类错误:区分取消、Hyper 未授权、Copilot 模型未启用、通用 provider 错误、 传输错误,写成带标题+说明的 finish 记录给 UI 显示。 这里的 Hyper 是 Charm 自家的托管模型网关(provider 名就叫 hyperinternal/agent/hyper/provider.go:1);它返回 401 时提示语会引导用户重新跑 crush authinternal/agent/agent.go:1161)。

第 3 步是最容易被忽略、也最致命的一步——它保证会话历史永远是「协议合法」的, 所以取消后你还能接着聊。

还有一个更早的窗口:取消发生在「已注册活跃 run」但「assistant 消息还没建出来」之间。 这时 currentAssistant == nil,走 persistCanceledTurn 单独补一条记录 (internal/agent/agent.go:508),不然用户会看到一次「什么都没发生」的提交。


8. 子代理:把大任务丢出去

agent 工具让主模型开一个子会话跑独立任务(internal/agent/agent_tool.go:26)。 四个设计点:

  • 子会话 ID 是拼出来的{父消息ID}$$工具调用IDinternal/session/session.go:351 CreateAgentToolSessionID), 所以 UI 能从任何一个子会话反查它挂在哪条消息的哪次调用下。
  • 花费向上累加:子会话结束把 cost 加到父会话,且失败也不丢弃已产出的结果 (internal/agent/coordinator.go:1483 updateParentSessionCost)。
  • 子代理不触发 hookswrapToolsWithHooksisSubAgent 时直接返回原始工具, 免得用户的 PreToolUse 脚本被一次委派放大成 N 次执行 (internal/agent/hooked_tool.go:31)。
  • 子代理拿不到 question 工具:它是交互专属的(internal/agent/coordinator.go:733-736)。

9. 代码地图

主题文件路径符号名
一次 turn 的主函数internal/agent/agent.gosessionAgent.Run
入场决策 / 排队internal/agent/agent.goenqueueCalldrainQueueForStep
accept 序号与取消水位internal/agent/agent.goBeginAcceptedendAcceptedcanceledBySeqCancel
取消后的补账internal/agent/agent.gopersistCanceledTurnpublishCanceledQueueDrops
自动摘要internal/agent/agent.goSummarizebuildSummaryPrompt
循环检测internal/agent/loop_detection.gohasRepeatedToolCallsgetToolInteractionSignature
终结事件合流internal/agent/coordinator.gocoordinator.runMarkRunCompletePublished
终结事件的相关性internal/agent/runid.goRunIDFromContext
子代理internal/agent/agent_tool.gointernal/agent/coordinator.goagentToolrunSubAgent
消息去抖落库internal/message/message.goservice.UpdateshouldFlushNowFlushAll
事件广播语义internal/pubsub/broker.goBroker.PublishBroker.PublishMustDeliver