上下文与投影
ContextMessage 与线上消息的分离、投影器的配对修复与 strict 模式、工具结果渲染、动态工具与注入格式。
两套消息
Kimi Code 把「引擎持有的历史」与「发给 provider 的消息」分开,这一点 v2 没变:
| 概念 | 类型 | 位置 |
|---|---|---|
| 内部历史 | ContextMessage | src/agent/contextMemory/types.ts |
| 线上消息 | Message | src/llm-adapter/contract/message.ts |
ContextMessage 就是 Message 加上一组模型看不到的字段(types.ts:122):
export type ContextMessage = Message & {
readonly id?: string;
readonly providerMessageId?: string;
readonly origin?: PromptOrigin | undefined;
readonly isError?: boolean;
toolCallDisplays?: Record<string, ToolInputDisplay>;
readonly note?: string;
};最关键的是 origin:PromptOrigin 有 12 种来源,包括用户输入、skill 激活、插件命令、注入(injection)、shell 命令、压缩摘要、系统触发、后台任务、cron、hook 结果、重试。压缩保留哪些 user 消息、界面怎么显示一条消息,都看它。
历史归 Agent scope 的 IAgentContextMemoryService 所有。循环产生的内容以 step.begin、content.part、tool.call、tool.result、step.end 这些 LoopRecordedEvent 写入,由 contextMemory/loopEventFold.ts 折叠成消息。
投影器
投影在 src/agent/contextProjector/:纯函数在 projection.ts(428 行),contextProjectorService.ts 包一层策略与遥测。v1 的两个布尔开关(dropOrphanToolResults、synthesizeMissing)没有了,换成两个入口:
export function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Message[] {
const layout = sliceLayout(history);
return flattenBlocks(pairBlocks(history, layout, onAnomaly), layout, onAnomaly);
}
export function projectStrict(
history: readonly ContextMessage[],
onAnomaly?: OnAnomaly,
): Message[] {
const projected = project(history, onAnomaly);
return dropLeadingNonUserMessages(
mergeConsecutiveAssistantMessages(dedupeDuplicateToolCalls(projected, onAnomaly), onAnomaly),
onAnomaly,
);
}ProjectionPolicy 只有两个维度:structure?: 'strict' 与 media?: 'degraded' | { strip }。平时走 project();provider 报可恢复的结构错误时,请求服务才切到 projectStrict() 重发;媒体策略在 413 与图片格式错误时切换(见回合收敛)。
project() 修什么
pairBlocks() 把每个 assistant 的 tool call 与后面的 tool 结果配成「交换」,flattenBlocks() 再按交换展开。每处修复记为一个 ProjectionAnomaly:
- 孤儿结果:找不到对应 tool call 的 tool 结果直接丢弃(
orphan_tool_result_dropped)——它在严格 provider 上是非法报文,对模型也没用。 - 错位结果:tool 结果与调用之间夹了别的消息,展开时挪回调用后面(
tool_result_reordered)。 - 缺失结果:一律补一条合成结果(
tool_result_synthesized):
const TOOL_INTERRUPTED_TEXT =
'Tool result is not available in the current context. Do not assume the tool completed successfully.';- 空内容:纯空白的文本块、只剩空洞内容的消息被丢掉;相邻的普通用户输入合并成一条。
v1 区分「末尾缺结果」与「非末尾缺结果」:末尾的可能还在执行,不能提前关掉。v2 的回合请求总在一个 step 的工具全部收齐之后才组装,后台工具也会先回一条 async running 的确认结果,所以投影无条件补齐也不会误关正在跑的调用;trailing 标记只决定要不要上报——末尾补齐不计入告警。
另一个保护对应 v1「估算切片 token 时不能误删」的顾虑:切片里若一条 assistant 消息都没有(sliceLayout 里的 sizing),tool 消息不参与配对,也就不会被当成孤儿删掉。
有修复时会记一条 repaired the request to keep it wire-valid 日志并上报 context_projection_repaired 遥测;同样的修复组合连续出现时只报一次。
工具结果渲染
contextMemory/toolResultRender.ts 把工具结果渲染成模型看到的样子:
export function renderToolResultForModel(result: RenderableToolResult): ContentPart[] {
const rendered = renderStatus(result);
if (result.note === undefined || result.note.length === 0) return rendered;
const only = rendered[0];
if (rendered.length === 1 && only?.type === 'text') {
return [textPart(only.text + '\n' + result.note)];
}
return [...rendered, textPart(result.note)];
}- 出错的结果前面加
<system>ERROR: Tool execution failed.</system>,空输出换成<system>Tool output is empty.</system>。 note是「只给模型看」的侧信道,就在这里接到输出末尾(参见上一章的工具结果契约)。
动态工具
实验性的 tool-select 打开后,部分工具只以名字公告,模型用 select_tools 按需加载完整定义。相关上下文有两种:公告消息(origin.variant 为 loadable-tools,内容是 <tools_added> / <tools_removed> 名单)与携带 tools 声明的动态 schema 消息。
agent/toolSelect/dynamicTools.ts 的 stripDynamicToolContext() 在压缩前把两者剥掉:公告整条删除,schema 消息去掉 tools 字段。压缩之后,提醒服务按当前状态重新公告(见提示词与注入)。
注入的格式
注入上下文的系统信息都用结构化标记,与用户原话区分开:
- 提醒类注入统一包成
<system-reminder>块(features/reminder/systemReminder.ts)。 - 后台任务的完成通知由
agent/task/notificationXml.ts渲染成<notification id="…" category="…" type="…">块。
prompt cache key
v1 的投影器会算哈希;v2 的投影器不再管缓存键。prompt cache key 直接用会话 ID——agent/profile/profileService.ts:471 的 cacheKey: this.sessionContext.sessionId,再由协议 trait 翻译成各家的字段(Kimi 是 prompt_cache_key)。请求服务里的 createHash 只给 system prompt 与工具表算指纹,用于日志去重和工具表快照。
为什么值得这么复杂
| 场景 | 朴素实现的后果 | v2 的处理 |
|---|---|---|
| 压缩后历史被切片 | 孤儿结果 → provider 400 | 丢弃孤儿 |
| 用户中途取消 | 悬空的 tool call | 补合成结果 |
| 并发工具先后完成 | 结果错位 | 按交换重排 |
| fork 继承半截交换 | 请求被拒 | 继承时补「仍在执行、结果未知」 |
| provider 仍嫌结构不对 | 反复 400 | strict 投影重发 |
修复逻辑只有一份纯函数,可以单独测试;主回合请求与压缩请求都经过它。
下一篇:上下文压缩。