如果 Agent 类是一个乐团的指挥,那么 TurnFlow 就是指挥挥动的手臂 --- 它负责将乐谱上的每个音符变为实际的演奏。从用户按下回车那一刻起,到 Agent 吐出最后一个字,TurnFlow 掌管了期间的每一个决策、每一次 LLM 调用、每一个工具的调度。本文深入剖析这个核心循环引擎的完整生命周期。
1. TurnFlow 概述
TurnFlow 是 Agent 的核心执行引擎,定义在 packages/agent-core/src/loop/run-turn.ts。它的职责可以概括为:
- 管理从接收到用户 prompt 到产生最终回复的完整循环
- 一个 Turn 可能包含多次 LLM 调用 --- 因为工具调用会触发新的 generate step
- 聚合 token 用量 --- 跨 step 累计 usage,在 turn 结束时统一上报
- 执行收敛控制 --- max steps、abort 检查、continuation hook
从代码签名看,runTurn 接受一个丰富的输入对象 RunTurnInput,返回一个 TurnResult:
typescript
export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
// input 包含: turnId, signal, llm, buildMessages,
// tools/buildTools, hooks, maxSteps, ...
}
interface TurnResult {
stopReason: LoopTurnStopReason; // 'end_turn' | 'aborted'
steps: number;
usage: TokenUsage;
}
关键区别:Turn 和 Step 是两个层次。一个 Turn 是用户的一次完整交互;一个 Step 是一次 LLM 往返(请求 + 响应 + 工具执行)。当模型返回 tool_use 时,Turn 会继续循环产生新的 Step。
2. 完整循环流程
TurnFlow 的核心是一个 while(true) 循环。在每次迭代中执行一个 Step,根据 Step 的返回决定是继续循环还是终止。以下是逐步骤的详细展开。
Context Injection(上下文注入)
在每个 Step 开始前,executeLoopStep 调用 hooks.beforeStep()。这个钩子由上层 Agent 注入,负责准备当前 Step 所需的上下文:
- InjectionManager 注入插件提醒(如定时任务、系统通知)
- SkillManager 注入激活技能的提示词
- GoalMode 注入目标状态(如果存在 active goal)
- ConfigState 注入系统配置信息
javascript
// turn-step.ts: executeLoopStep
if (hooks?.beforeStep !== undefined) {
const beforeStep = await hooks.beforeStep({
turnId, stepNumber: currentStep, signal, llm,
});
if (beforeStep?.block === true) {
throw new Error(beforeStep.reason ?? `Step ${currentStep} was blocked`);
}
}
设计要点: beforeStep 运行 Compaction(在上下文接近窗口上限时压缩消息),因此工具表解析被延迟到 beforeStep 之后。如果先解析工具表再执行注入,Compaction 丢弃的动态 schema 会导致工具表与消息状态不一致。
System Prompt Assembly(系统提示词组装)
调用 buildMessages() 构建发送给 LLM 的完整消息列表。这个消息列表通常包含:
- 基础系统提示词 --- Agent Profile 定义的 behavior prompt
- 工具描述 --- 每个可用工具的 JSON Schema(作为 system message 中的 tools 字段)
- 注入的上下文 --- Step 1 中注入的所有提醒和状态
- 对话历史 --- 来自 ContextMemory 的消息序列
buildMessages 是一个由外部传入的函数(LoopMessageBuilder 类型),它将 ContextMemory 中的消息转换为 LLM 可接受的 Message[] 格式。除默认构建器外,还存在两个备用构建器:
buildMessagesStrict--- 保证严格的 wire-compliant 格式(用于恢复结构错误)buildMessagesMediaDegraded--- 旧媒体替换为文本标记(用于恢复 413 错误)buildMessagesMediaStripped--- 所有媒体替换为文本标记(用于恢复图片格式错误)
LLM Generate(调用大模型)
通过 chatWithRetry() 调用 llm.chat(),核心流程:
- 流式接收 tokens --- 通过
onTextDelta、onThinkDelta、onToolCallDelta回调实时推送 - Usage 追踪 --- 响应返回后立即调用
recordUsage()(即使在工具执行阶段 abort,已消耗的 token 也不会丢失) - 解析响应 --- 从
LLMChatResponse中提取文本内容和工具调用列表
javascript
// 流式回调的创建
function createChatStreamingCallbacks(...): ChatStreamingCallbacks {
return {
onTextDelta: (delta) => dispatchEvent({ type: 'text.delta', delta }),
onThinkDelta: (delta) => dispatchEvent({ type: 'thinking.delta', delta }),
onToolCallDelta: (delta) => dispatchEvent({
type: 'tool.call.delta',
toolCallId: delta.toolCallId,
name: delta.name,
argumentsPart: delta.argumentsPart,
}),
// ...
};
}
Response 的解析规则由 deriveStepStopReason() 定义:
matlab
function deriveStepStopReason(response: LLMChatResponse): LoopStepStopReason {
switch (response.providerFinishReason) {
case 'truncated': return 'max_tokens';
case 'filtered': return 'filtered';
case 'paused': return 'paused';
case 'completed':
case undefined: return response.toolCalls.length > 0 ? 'tool_use' : 'end_turn';
case 'tool_calls': return response.toolCalls.length > 0 ? 'tool_use' : 'unknown';
// ...
}
}
Response Processing(响应处理)
根据 stopReason 决定下一步动作:
end_turn--- 模型没有请求工具,文本已经流式推送给 UI,Turn 自然结束tool_use--- 模型请求执行工具,进入工具执行流程max_tokens/ paused--- 流式响应中断,如果残留未完成工具调用则记录为未执行(recordUnexecutedToolCalls)filtered--- 内容被过滤,跳过工具执行
content.part 事件在此阶段被分派 --- 每个文本段落和思考段落都有一个唯一的 UUID,用于构成 transcript 中的内容块。
Tool Execution(工具执行)
当模型返回 tool_use 时,runToolCallBatch 接管。工具执行分为五个阶段,严格按 provider 顺序:
Phase A: 预检(preflightToolCall) 验证每个工具调用:工具名是否存在?参数 JSON 是否可解析?参数是否匹配 schema?任何一个检查失败,该调用被标记为 rejected,生成错误结果但不中断批次。
Phase B: 准备钩子(prepareToolExecution) 按 provider 顺序调用 hooks.prepareToolExecution。钩子可以:
- block --- 阻止执行,返回说明原因
- synthetic result --- 直接返回合成结果,跳过实际执行
- updated args --- 修改调用参数
Phase C: 授权钩子(authorizeToolExecution) 调用 hooks.authorizeToolExecution,同样可以 block 或返回 synthetic result。这是权限管理的入口点(YOLO / Auto / Default 模式在这里生效)。
Phase D: 并发调度(ToolScheduler) 通过 ToolScheduler 实现智能并发:
- 不冲突的工具调用 并发执行
- 资源冲突的工具调用 串行化 (通过
ToolAccesses.conflict()判断) - 首次
stopBatchAfterThis的工具会跳过后续所有工具
kotlin
// tool-scheduler.ts: ToolScheduler
add(task: ToolCallTask<Result>): Promise<Result> {
const scheduledTask = { ...task, result: createControlledPromise() };
if (this.isBlocked(task, this.queuedTasks)) {
this.queuedTasks.push(scheduledTask); // 排队等待
} else {
this.start(scheduledTask); // 立即启动
}
return scheduledTask.result;
}
Phase E: 结果收尾(finalizeToolResult) 所有工具执行完毕后,依次调用 hooks.finalizeToolResult。随后按 provider 顺序分派 tool.result 事件,确保每个 tool.call 都有一个配对的 tool.result。
Loop Decision(循环决策)
Turn 主循环根据 Step 结果做决策:
kotlin
// run-turn.ts 主循环核心
while (true) {
signal.throwIfAborted();
if (maxSteps !== undefined && steps >= maxSteps) throw createMaxStepsExceededError(maxSteps);
steps += 1;
const stepResult = await executeLoopStep({...});
// 工具调用 → 继续循环
if (stepResult.stopReason === 'tool_use') continue;
// 终端 stop reason → 检查 continuation
const continuation = await hooks?.shouldContinueAfterStop?.({...});
if (continuation?.continue !== true) break;
}
| Step 结果 | Turn 动作 |
|---|---|
tool_use |
继续循环 --- 工具结果已追加到 ContextMemory,下一轮 LLM 会看到 |
end_turn |
调用 shouldContinueAfterStop hook;默认终止 Turn |
max_tokens |
同上 --- 通常触发 continuation(让模型继续未完成的输出) |
达到 maxSteps |
抛出 MaxStepsExceededError |
| AbortSignal 触发 | 立即中断并返回 aborted |
| 媒体投影降级 | 后续 Step 使用降级构建器(mediaDegradedActive / mediaStrippedActive) |
Turn Completion(Turn 完成)
Turn 正常结束时执行以下收尾工作:
- 聚合 usage 返回 --- TurnResult 包含 stopReason、steps、usage
- AgentRecords 持久化 --- 通过 Wire 系统写入
wire.jsonl,记录完整的交互历史 - 触发 Turn 后钩子 ---
step.end事件携带完整的 timing 信息(TTFT、decode 时长、stream 时长) - Goal Mode 检查 --- 通过
shouldContinueAfterStop判断是否需要自动发起 continuation turn
3. 上下文管理深入
ContextMemory 数据结构
ContextMemory 定义在 packages/agent-core-v2/src/agent/contextMemory/,由 IAgentContextMemoryService 接口暴露:
scss
export interface IAgentContextMemoryService {
get(): readonly ContextMessage[]; // 获取完整历史
append(...messages: ContextMessage[]): void; // 追加消息
appendLoopEvent(event: LoopRecordedEvent): void; // 追加 loop 事件
clear(): void; // 清空历史
undo(count: number): UndoCut; // 撤销末尾消息
applyCompaction(input: ContextCompactionInput): ContextCompactionResult; // 应用压缩
}
ContextMessage 扩展了 kosong 的 Message 类型:
typescript
export type ContextMessage = Message & {
readonly id?: string;
readonly providerMessageId?: string;
readonly origin?: PromptOrigin | undefined; // 消息来源追踪
readonly isError?: boolean;
readonly note?: string;
};
PromptOrigin 是一个 discriminated union,精准记录每条消息的来源 --- 用户输入、技能激活、插件命令、上下文注入、shell 命令、定时任务、hook 结果、compaction summary、系统触发等十余种类型。这种精细的来源标记是 V2 架构的 key improvement:replay 时可以准确还原每条消息的生命周期。
Token 计数机制
Token 计数由 IAgentContextSizeService(packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts)管理,采用 双轨制:
typescript
export interface ContextSize {
readonly size: number; // measured + estimated(总 token 数)
readonly measured: number; // 实测部分(provider 返回的 usage)
readonly estimated: number; // 估算部分(tiktoken 或等效算法)
}
- measured 来自 LLM 返回的
tokenUsageTotal(usage)--- 包含 input_cache_read、input_cache_creation、input_other 和 output 的总和 - estimated 通过
estimateTokensForMessages()估算未经过实测请求的消息 - 由于 measured 值来自 provider 响应,它在 replay 时是确定性的 --- 不需要重新计算
Token 计数的写入时机是在每次 LLM 调用之后:llmRequester 调用 contextSizeService.measured(input, output, usage),触发 contextSizeMeasured Op 写入 wire。
System Prompt 缓存优化
kimi-code 利用 LLM provider 的 prompt caching 特性来降低重复请求的成本。在 V2 架构中,上下文大小估算支持标记 "已缓存" 和 "未缓存" 两个区域:
- 已缓存前缀 --- system prompt + 只读注入内容,这些在多个 Turn 之间保持稳定,provider 可以复用 KV cache
- 动态增长尾部 --- 对话历史中新增的 user/assistant/tool_result 消息,每轮重新计算
这使得 Compaction 的作用不仅是释放 token 空间,还会 创建新的可缓存前缀 --- 压缩后的 summary 变成了一个固定的 "锚点"。
4. 工具调用处理
并行 vs 串行
kimi-code 的工具调度器(ToolScheduler)实现了对于资源冲突的 自适应并发 。每个工具声明其资源访存集合(ToolAccesses):
- 无冲突 --- 两个工具操作不同的文件 / 资源,并发执行
- 有冲突 --- 一个工具写入某文件,另一个读取或写入同一文件,串行化
- 全局锁 --- 工具声明
ToolAccesses.all(),阻塞所有其他工具直到它完成 - 无资源交互 --- 工具声明
ToolAccesses.none(),永远不与其他工具冲突
调度算法采用 work-conserving queue:每个任务到达时,如果能立即启动(不与任何 active 或 queued 任务冲突)就启动;否则排到队尾,等待前方任务完成时重新检查。
工具结果合并到上下文
每个工具执行后,其结果通过 dispatchEvent({ type: 'tool.result', ... }) 进入事件系统。在 V2 中,contextAppendLoopEvent Op 将 tool.result 事件折叠为 tool_result 消息追加到 ContextMemory:
php
// 简化的结果合并流程
contextMemory.appendLoopEvent({
type: 'tool.result',
toolCallId: call.id,
result: { output: '...', isError: false },
});
// → 被 fold 为 ContextMessage { role: 'tool_result', content: [...] }
下一轮 LLM 调用时,buildMessages() 从 ContextMemory 读取所有消息,包括刚追加的 tool_result,模型就能基于工具执行结果做下一步推理。
工具调用失败处理
工具执行失败有多种可能的路径,每种都有对应的处理方式:
| 失败类型 | 处理方式 |
|---|---|
| 工具未找到 | 生成 Tool "X" not found 错误结果 |
| 参数 JSON 解析失败 | 记录 isError: true 的结果 |
| 参数 schema 校验失败 | 生成 Invalid args for tool "X": ... 结果 |
resolveExecution 抛出 |
捕获后生成错误结果,区分 PathSecurityError vs 普通错误 |
| 执行时 AbortSignal 触发 | 用户取消:明确告知模型;程序 abort:中性消息 |
| 执行抛出运行时错误 | Tool "X" failed: ...,isError 标记 |
| 最终化钩子失败 | 不泄露原始输出;生成 finalizeToolResult hook failed 错误 |
Grace Timeout
工具执行有一个 2000ms 的 grace timeout:当 AbortSignal 触发后,调度器等待 2 秒以便工具自行终止。如果工具在此期间完成,返回实际结果;如果超时仍未完成,生成一个合成的错误结果,让 Turn 可以正常结束而不是永远挂起。
javascript
// tool-call.ts
const GRACE_TIMEOUT_MS = 2_000;
async function raceExecuteWithGraceTimeout(
executePromise: Promise<ExecutableToolResult>,
signal: AbortSignal,
toolName: string,
): Promise<ExecutableToolResult> {
// 竞速:实际执行 vs grace timer
return Promise.race([executePromise, graceSentinel]);
}
5. Compaction 机制
当对话长度超过 LLM 上下文窗口的 85%(默认 triggerRatio: 0.85),Compaction 自动启动。这是 kimi-code 处理长对话的核心机制。
触发条件
CompactionStrategy(packages/agent-core/src/agent/compaction/strategy.ts)定义了双层阈值:
arduino
export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
triggerRatio: 0.85, // 85% 窗口触发
blockRatio: 0.85, // 与 trigger 相同 → 同步压缩
reservedContextSize: 50_000, // 为输出预留 50K tokens
maxCompactionPerTurn: Infinity, // 不限压缩次数
maxOverflowCompactionAttempts: 3, // 压缩后仍溢出,最多重试 3 次
};
当 shouldCompact(usedSize) 返回 true,beforeStep 钩子同步执行压缩,在 LLM 调用之前保证上下文在窗口限制内。
Full Compaction vs Micro Compaction
项目中有两种压缩策略:
- Full Compaction (
full.ts)--- 用专门的 compaction 模型生成一份紧凑的对话摘要。摘要消息作为user角色(标记origin: { kind: 'compaction_summary' })插入压缩位置,替代被删除的早期消息。 - Micro Compaction (
micro.ts)--- 轻量级的规则裁剪,不依赖额外的模型调用。用于快速削减少量超出预算的 token。
压缩策略:保留什么、丢弃什么、总结什么
| 内容 | 策略 |
|---|---|
| System prompt(含 tools schema) | 始终保留 --- 不可压缩的基础上下文 |
| 最近的用户 / 助手消息 | 保留尾部 --- 最近的对话是当前推理的关键 |
| 早期的用户消息 | 选择性保留头部 --- 当 pool 溢出 budget 时,保留最老的几条 user input |
| 中间的 assistant + tool 消息 | 汇总为 summary --- 最核心的压缩目标 |
| 最早的不在保留窗口的消息 | 被丢弃 --- droppedCount 记录丢失数量 |
| 动态加载的工具 schema | 随压缩丢弃 --- 需要工具重新加载 |
压缩后的上下文一致性
Compaction 通过以下机制保证压缩后的上下文仍然可用:
- Summary prefix --- 摘要前附加一段 instruction 前缀,告诉后续模型 "以下是对之前对话的摘要,这不是用户的真实消息"
compactionHandoff---buildContextCompactionShape确保压缩后的消息数组满足严格的 wire-compliant 格式context.spliced事件 --- 压缩后发布 splicing 事件,通知所有订阅者上下文已变更- Token 计数重置 ---
contextSizeMeasured({ length, tokens: tokensAfter })将前缀重置为压缩后的值
6. 错误处理与恢复
LLM 调用失败
chatWithRetry(packages/agent-core/src/loop/retry.ts)实现了多层次的错误恢复:
ini
// 默认最大尝试次数
export const DEFAULT_MAX_RETRY_ATTEMPTS = 10;
// 指数退避参数
const BASE_DELAY_MS = 500; // 首次延迟 0.5s
const MAX_DELAY_MS = 32_000; // 最大延迟 32s
const RETRY_FACTOR = 2; // 每次加倍
const JITTER_FACTOR = 0.25; // 25% 抖动
| 错误类型 | 恢复策略 |
|---|---|
| 可重试错误(rate limit、服务器过载) | 指数退避重试,最多 10 次 |
Retry-After header |
服务器指定的等待时间覆盖本地退避 |
| HTTP 413 请求体过大 | 逐步降级:normal → media-degraded → media-stripped |
| 图片格式错误 | 直接跳到 media-stripped 投影 |
| 请求结构不合规(strict provider) | 切换到 buildMessagesStrict 重发一次 |
| 不可重试错误(认证失败等) | 立即抛出,不重试 |
工具执行失败
工具执行失败不会导致 Turn 中断 --- 失败结果(带 isError: true 标记)被追加到 ContextMemory,模型会在下一轮看到错误信息并自行调整。只有当所有工具调用都失败且 LLM 无法恢复时,Turn 才会不正常结束。
Turn 级别的重试策略
Turn 本身没有重试机制 --- 重试在 Step 级别(LLM 调用)和内部恢复(媒体投影降级)完成。但通过 shouldContinueAfterStop hook,可以在 max_tokens 停止后自动发起 continuation turn,让模型继续未完成的输出。
优雅降级
当上下文超出模型限制时的降级链:
- Compaction --- 压缩对话历史释放空间
- 媒体投影降级 --- 旧媒体替换为文本标记
- 完全媒体剥离 --- 所有媒体替换为文本标记
- 溢出 compaction 循环 --- 压缩后仍溢出,压缩-重试循环,最多 3 次
7. 取消与中断
用户中断的处理流程
kimi-code 使用 AbortSignal 作为统一的取消信号传播机制。当用户按下 "停止" 按钮:
AbortController.abort()被调用,signal.reason设置为用户取消标志- 每个
await点之后检查signal.throwIfAborted() - 工具执行中的
abortable()wrapper 将 sleep 等操作变为可取消
go
// run-turn.ts 的中断处理
catch (error) {
if (isAbortError(error) || signal.aborted) {
const interruptReason =
isUserCancellation(signal.reason) || isUserCancellation(error)
? 'user_cancelled' // 显式区分用户取消
: 'aborted'; // 程序化 abort / timeout
dispatchEvent(makeInterruptedEvent('aborted', steps, activeStep, ...));
return { stopReason: 'aborted', steps, usage };
}
throw error; // 非 abort 错误继续传播
}
取消信号的传播路径
AbortSignal 从 runTurn 的主循环流向:
executeLoopStep→chatWithRetry→llm.chat()→ provider HTTP 请求runToolCallBatch→executeTool→ 工具execute()函数ToolScheduler→ 每个 pending 的 tool task
关键设计:工具执行阶段即使被 cancel,调度器仍然 排空所有 pending 任务 (通过 Promise.allSettled(pendingResults)),确保每个 tool.call 都配对 tool.result,避免上下文状态不一致。
中断后的状态恢复
用户取消后:
- 已消耗的 token 不会丢失 ---
recordUsage()在 LLM 调用返回后立即执行,先于 abort 检查 - 未执行工具调用被记录 --- 中断时工具调用得到配对的结果事件(明确告知模型"用户中断了此工具")
- 会话可恢复 --- 下次用户发送消息时,ContextMemory 中包含上一个未完整 Turn 的所有内容
typescript
// 用户取消 vs 程序中断的区分
function abortedToolOutput(toolName: string, signal: AbortSignal): string {
if (isUserCancellation(signal.reason)) {
return `The user manually interrupted "${toolName}" ...` +
`This was a deliberate user action, not a system error...`;
}
return `Tool "${toolName}" was aborted`;
}
8. 总结
TurnFlow 是 kimi-code 中最核心的执行单元。从这段看似简单的 while(true) 循环出发,它承载了上下文注入、LLM 调用、流式推送、工具执行并发调度、Compaction、错误恢复、取消传播等全部职责。
理解 TurnFlow 的关键洞察:
TurnFlow = Step 循环 + ContextMemory 变更追踪 + 工具调度 + Compaction 触发 + 错误恢复链。 它不负责 "Agent 应该做什么"(那是 Profile 和 System Prompt 的职责),只负责 "Agent 执行这件事" --- 而且要做到可靠、可恢复、可观测。
下一篇将深入 工具系统 --- 理解工具如何被注册、发现、校验和调度,以及 Dynamic Tool Loading 和 Progressive Disclosure 的实现。