跳到主要内容

聊天主循环:一条消息端到端

本章一句话: 看 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:908-971)逻辑很直白:

# 示意,非源码:按回合丢历史,保护最近 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:942-970)。入口是 buildContextWithMetadata(contextBuilder.ts:1029),它还会顺带产出「哪些被选入 / 哪些因 out_of_budget 被排除」的元数据,供 Trace/Inspector 解释。

4. 统一循环:processStream

准备好 messagestools,进入 processStream(process.ts:303)。注释点题:

Unified stream processor. Handles both simple completions and multi-turn tool-calling loops in a single code path.(process.ts:299-302)

外层是 while (true)(process.ts:337),一轮 = 一次 provider 调用:

  1. coreStream(conversationMessages, ...) 拿到这一轮的流式事件(process.ts:340)。
  2. 内层 for await (const event of stream):
    • 检查 abortSignal,被取消就 finalize 成 user-canceled 并返回(process.ts:355-365)。
    • event.type === 'permission':provider 级授权(如 ACP)。插一个 pending 授权块,回调 hooks,不打断流继续等(process.ts:367-380)。
    • 其它事件:accumulate(state, event) 累积成消息块,echo.schedule() 节流刷给 renderer(process.ts:382-383)。
  3. 一轮流结束后判断 break 条件(process.ts:410-411):
    • stopReason !== 'tool_use' → 没要调工具,跳出循环去收尾。
    • completedToolCalls.length === 0 → 同理跳出。
  4. 否则执行工具:executeTools(...)(process.ts:425-445),把结果接回 conversationMessages,继续下一轮 while

这就是「同一段代码路径」的精髓:要不要再来一轮,只由 stopReason 和有没有工具调用决定,没有「补全模式 vs 工具模式」的分叉。

循环里的三个出口

executeTools 返回后有三种特殊收场(process.ts:450-490):

情况处理返回 status
terminalErrorfinalizeErrorerror
pendingInteractions.length > 0finalizePaused,暂停等用户响应paused
shouldYieldForPendingInput()finalize,让位给排队输入completed(stopReason pending_input)

暂停(paused)是 agent 体验的关键:模型要写文件/跑命令需要授权时,循环停在这里,把 pending 交互抛回 renderer;用户点「批准」后走 respondToolInteraction(index.ts:1468)恢复——恢复用的上下文由 buildResumeContext 重建(contextBuilder.ts:1155)。

工具/技能热更新

如果某个工具调用激活了新 Skill(executed.toolsChanged),循环会在原地刷新工具集和 system prompt(process.ts:492-515):refreshTools(activeSkillNames) 重新装载工具,refreshSystemPrompt(...) 重写系统提示并替换首条 system 消息(replaceLeadingSystemMessage,process.ts:267-281)。换句话说,会话中途装上的 Skill 立刻对后续轮次生效,不必重开会话。

5. 收尾:落库 + 追加 Tape

正常跑完调 finalize(state, io)(process.ts:549),把累积的 assistant 块写回稳定的消息记录;每一轮工具结束还会 appendAssistantToolFactsSnapshot(...) 把工具 facts 快照进 Tape(process.ts:448)。错误/取消有各自的 finalize 路径,保证任何结局都在 Tape 上留下痕迹(见 01)。

6. 巧妙之处

  • echo 节流:流式 token 不是来一个刷一个,而是 echo.schedule() 批量节流刷新 renderer,首轮就绪还有 onFirstProviderRoundReady 回调(process.ts:399-407),兼顾流畅与性能。
  • 预算把工具也算进去:estimateToolReserveTokens 先扣工具 schema 占的 token 再裁历史,避免「历史塞满、工具定义却挤不下」。
  • 暂停即返回、恢复即重建:授权暂停不是挂起一个 Promise 等着,而是干净地 return { status: 'paused' },恢复时用 buildResumeContext 重新组上下文——天然契合 Tape 的「过程可恢复」。

7. 边界与局限

  • MAX_TOOL_CALLS = 128(process.ts:23):单次消息内工具调用上限,超了 planTerminalReason = 'max_steps' 收尾(process.ts:414-420),防失控。
  • 上下文窗口溢出会被识别成 isContextWindowErrorLike 并单独收尾(process.ts:528-538),而不是当普通错误。

8. 代码地图

主题文件路径符号名
消息处理入口src/main/presenter/agentRuntimePresenter/index.tsprocessMessagerespondToolInteraction
统一 stream/tool 循环src/main/presenter/agentRuntimePresenter/process.tsprocessStream
流事件累积src/main/presenter/agentRuntimePresenter/accumulator.tsaccumulate
工具执行 / 收尾src/main/presenter/agentRuntimePresenter/dispatch.tsexecuteToolsfinalizefinalizePausedfinalizeError
上下文预算src/main/presenter/agentRuntimePresenter/contextBuilder.tsbuildContextWithMetadataselectTurnHistoryTurnsbuildResumeContext
压缩src/main/presenter/agentRuntimePresenter/compactionService.tsCompactionServiceprepareForNextUserTurn
实时 echosrc/main/presenter/agentRuntimePresenter/echo.tsstartEcho