kimi-code 深度掌握系列文章-对话循环TurnFlow(五)

如果 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 --- 通过 onTextDeltaonThinkDeltaonToolCallDelta 回调实时推送
  • 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 计数由 IAgentContextSizeServicepackages/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 处理长对话的核心机制。

触发条件

CompactionStrategypackages/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 Compactionfull.ts)--- 用专门的 compaction 模型生成一份紧凑的对话摘要。摘要消息作为 user 角色(标记 origin: { kind: 'compaction_summary' })插入压缩位置,替代被删除的早期消息。
  • Micro Compactionmicro.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 调用失败

chatWithRetrypackages/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,让模型继续未完成的输出。

优雅降级

当上下文超出模型限制时的降级链:

  1. Compaction --- 压缩对话历史释放空间
  2. 媒体投影降级 --- 旧媒体替换为文本标记
  3. 完全媒体剥离 --- 所有媒体替换为文本标记
  4. 溢出 compaction 循环 --- 压缩后仍溢出,压缩-重试循环,最多 3 次

7. 取消与中断

用户中断的处理流程

kimi-code 使用 AbortSignal 作为统一的取消信号传播机制。当用户按下 "停止" 按钮:

  1. AbortController.abort() 被调用,signal.reason 设置为用户取消标志
  2. 每个 await 点之后检查 signal.throwIfAborted()
  3. 工具执行中的 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 错误继续传播
}

取消信号的传播路径

AbortSignalrunTurn 的主循环流向:

  • executeLoopStepchatWithRetryllm.chat() → provider HTTP 请求
  • runToolCallBatchexecuteTool → 工具 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 的实现。

相关推荐
Loveyourself1 小时前
什么是cc中的session memory compact(background notes)?
agent
lucas_AI1 小时前
Skill-α:教 Agent 学会自己'改说明书'
llm·agent
HIT_Weston2 小时前
167、【Agent】【OpenCode】TuiThreadCmd(EvenSource)
人工智能·agent·opencode
DigitalOcean2 小时前
多款模型最高直降 50%:DigitalOcean 无服务器推理大降价
llm·agent
罗高3 小时前
Hermes Agent Token 机制深度解析
llm·agent
网易云信3 小时前
立即下载!帝王蟹(ClawHive)桌面客户端正式上线!
人工智能·agent
众人皆醒我独醉3 小时前
Ray Serve:把 LLM 推理当"分布式 Actor"调度——不是 Kubernetes,是 Python 原生
面试·llm·ai编程
玉鸯3 小时前
界面用完即消失:Agent 生成式 UI 的短暂性哲学与前端工程的未来
前端·llm·agent
组合缺一3 小时前
SolonCode v2026.8.4 发布:界面字体可调、22 种语言、记忆搜索增强
llm·agent·ai编程·solon·claudecode·opencode