数据截至 (上游 commit 9fb509bab3e0)
聊天主循环:一条消息端到端
本章一句话: 看 DeepChat 怎么把「用户发一句话」变成「模型流式回复 + 多轮工具调用 + 落库」,全程在一个统一循环里完成。
1. 它要解决的小问题
agent 聊天不是「发一句、回一句」。模型可能:回一段文字就结束,或要求调用工具、拿到结果后继续想、再调更多工具,直到收敛。如果用两套代码分别处理「纯补全」和「工具循环」,会很快失控。DeepChat 用一个循环统一两者。
2. 主线全景
怎么读:从上到下是 processMessage 的时间线。左列是准备工作,进入 processStream 后
是一个 while(true) 循环,出口由 stopReason 决定。
processMessage(sessionId, content)
│
├─ 1. 取 generationSettings(systemPrompt/temperature/maxTokens/...)
├─ 2. 解析 modelConfig、是否启用 context budget、interleaved reasoning
├─ 3. 装载工具定义 tools(MCP + 本地 agent tools) → 估 toolReserveTokens
├─ 4. 构造 system prompt(含 Skills)
├─ 5. ensureSessionTapeReady → 折叠出 historyRecords(来自 Tape)
├─ 6. (可选) compactionService.prepareForNextUserTurn ← 预算不够先压缩
├─ 7. contextBuilder 按 token 预算裁出要发的 messages
│
└─ 8. processStream({ messages, tools, coreStream, ... })
│
▼
┌─────────── while(true) ───────────────────────────┐
│ coreStream(...) ⇒ 流式事件 │
│ for each event: │
│ ├─ permission 事件 → 插授权块、回调 │
│ └─ 其它 → accumulate(state,event) + echo 节流 │
│ │
│ stopReason !== 'tool_use' ────────────────► break │
│ 没有 completedToolCalls ────────────────► break │
│ 否则 executeTools(...) │
│ ├─ terminalError → finalizeError → return error │
│ ├─ pendingInteractions → finalizePaused → 暂停 │
│ └─ 正常 → 把 tool 结果接回 conversationMessages │
│ (工具/技能变了则 refreshTools / refreshSystemPrompt) │
└──────────────────────────────────────────────────┘
│
└─ finalize(state, io) → 落库 + 追加 Tape facts
3. 准备阶段:预算先行
入口 AgentRuntimePresenter.processMessage(index.ts:888)。它的前半段全是为「这次到底发多少上下文」做预算:
getEffectiveSessionGenerationSettings取这次的运行时设置(index.ts:928)。capAgentRequestMaxTokens(maxTokens, contextBudgetLength)把输出预算压在上下文窗口内(index.ts:948)。loadToolDefinitionsForSession装载工具,estimateToolReserveTokens(tools)预留工具定义占的 token(index.ts:959-965)——工具 schema 本身也吃上下文,必须先扣掉。ensureSessionTapeReady(...)折叠 Tape 拿到historyRecords(index.ts:976-977),这就是上一章的 effective view。
如果预算不够,先压缩再继续:compactionService.prepareForNextUserTurn({...})(index.ts:992)会在进入流之前判断要不要压一段历史(压缩在 Tape 里只是加一个 compaction/* anchor,见 01)。
上下文裁剪:protected turns + 逐回合丢弃
真正裁上下文的是 contextBuilder。核心选择函数 selectTurnHistoryTurns(contextBuilder.ts:926-989)逻辑很直白:
# 示意,非源码:按回合丢历史,保护最近 N 个回合
def select_turns(turns, available_tokens, protected_count):
total = sum(t.tokens for t in turns)
if total <= available_tokens:
return turns # 放得下,全留
remaining = list(turns)
# 从最老的回合开始丢,但绝不丢「受保护」的最近几回合
while len(remaining) > protected_count and total > available_tokens:
total -= remaining.pop(0).tokens # 重点:整回合地丢,不切碎语义
return remaining # 还超就再对首回合做字符级截断
重点看两处:整回合丢弃(保持 user/assistant/tool 的回合完整,不会丢半个工具调用),以及 protected_count 兜底(再挤也保最近若干回合,fallbackProtectedTurnCount)。真实实现里如果丢完整回合还超,才会调 truncateContext 做更细的字符级截断(contextBuilder.ts:960-988)。入口是 buildContextWithMetadata(contextBuilder.ts:1047),它还会顺带产出「哪些被选入 / 哪些因 out_of_budget 被排除」的元数据,供 Trace/Inspector 解释。
4. 统一循环:processStream
准备好 messages 和 tools,进入 processStream(process.ts:429)。注释点题:
Unified stream processor. Handles both simple completions and multi-turn tool-calling loops in a single code path.(
process.ts:425-428)
外层是 while (true)(process.ts:463),一轮 = 一次 provider 调用:
coreStream(conversationMessages, ...)拿到这一轮的流式事件(process.ts:466)。- 内层
for await (const event of stream):- 检查
abortSignal,被取消就 finalize 成 user-canceled 并返回(process.ts:481-491)。 event.type === 'permission':provider 级授权(如 ACP)。插一个 pending 授权块,回调 hooks,不打断流继续等(process.ts:493-506)。- 其它事件:
accumulate(state, event)累积成消息块,echo.schedule()节流刷给 renderer(process.ts:508-509)。
- 检查
- 一轮流结束后判断 break 条件(
process.ts:536-537):stopReason !== 'tool_use'→ 没要调工具,跳出循环去收尾。completedToolCalls.length === 0→ 同理跳出。
- 否则执行工具:
executeTools(...)(process.ts:551-571),把结果接回conversationMessages,继续下一轮 while。
这就是「同一段代码路径」的精髓:要不要再来一轮,只由 stopReason 和有没有工具调用决定,没有「补全模式 vs 工具模式」的分叉。
循环里的三个出口
executeTools 返回后有三种特殊收场(process.ts:576-616):
| 情况 | 处理 | 返回 status |
|---|---|---|
terminalError | finalizeError | error |
pendingInteractions.length > 0 | finalizePaused,暂停等用户响应 | paused |
shouldYieldForPendingInput() | finalize,让位给排队输入 | completed(stopReason pending_input) |
暂停(paused)是 agent 体验的关键:模型要写文件/跑命令需要授权时,循环停在这里,把 pending 交互抛回 renderer;用户点「批准」后走 respondToolInteraction(index.ts:1468)恢复——恢复用的上下文由 buildResumeContext 重建(contextBuilder.ts:1173)。
工具/技能热更新
如果某个工具调用激活了新 Skill(executed.toolsChanged),循环会在原地刷新工具集和 system prompt(process.ts:618-641):refreshTools(activeSkillNames) 重新装载工具,refreshSystemPrompt(...) 重写系统提示并替换首条 system 消息(replaceLeadingSystemMessage,process.ts:393-407)。换句话说,会话中途装上的 Skill 立刻对后续轮次生效,不必重开会话。
5. 收尾:落库 + 追加 Tape
正常跑完调 finalize(state, io)(process.ts:675),把累积的 assistant 块写回稳定的消息记录;每一轮工具结束还会 appendAssistantToolFactsSnapshot(...) 把工具 facts 快照进 Tape(process.ts:574)。错误/取消有各自的 finalize 路径,保证任何结局都在 Tape 上留下痕迹(见 01)。
6. 巧妙之处
- echo 节流:流式 token 不是来一个刷一个,而是
echo.schedule()批量节流刷新 renderer,首轮就绪还有onFirstProviderRoundReady回调(process.ts:525-533),兼顾流畅与性能。 - 预算把工具也算进去:
estimateToolReserveTokens先扣工具 schema 占的 token 再裁历史,避免「历史塞满、工具定义却挤不下」。 - 暂停即返回、恢复即重建:授权暂停不是挂起一个 Promise 等着,而是干净地
return { status: 'paused' },恢复时用buildResumeContext重新组上下文——天然契合 Tape 的「过程可恢复」。
7. 边界与局限
MAX_TOOL_CALLS = 128(process.ts:24):单次消息内工具调用上限,超了planTerminalReason = 'max_steps'收尾(process.ts:540-546),防失控。- 上下文窗口溢出会被识别成
isContextWindowErrorLike并单独收尾(process.ts:654-664),而不是当普通错误。
8. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 消息处理入口 | src/main/presenter/agentRuntimePresenter/index.ts | processMessage、respondToolInteraction |
| 统一 stream/tool 循环 | src/main/presenter/agentRuntimePresenter/process.ts | processStream |
| 流事件累积 | src/main/presenter/agentRuntimePresenter/accumulator.ts | accumulate |
| 工具执行 / 收尾 | src/main/presenter/agentRuntimePresenter/dispatch.ts | executeTools、finalize、finalizePaused、finalizeError |
| 上下文预算 | src/main/presenter/agentRuntimePresenter/contextBuilder.ts | buildContextWithMetadata、selectTurnHistoryTurns、buildResumeContext |
| 压缩 | src/main/presenter/agentRuntimePresenter/compactionService.ts | CompactionService、prepareForNextUserTurn |
| 实时 echo | src/main/presenter/agentRuntimePresenter/echo.ts | startEcho |