08 上下文工程原理(为什么系统不会越聊越炸)
这篇只讲“上下文怎么做才稳定”。
核心源码入口
src/agents/context-window-guard.tssrc/agents/pi-embedded-runner/history.tssrc/agents/pi-embedded-runner/google.tssrc/agents/session-transcript-repair.tssrc/agents/pi-embedded-subscribe.tssrc/agents/pi-embedded-runner/run/compaction-timeout.tssrc/agents/pi-embedded-runner/run.ts
先记一个总公式
上下文稳定 = 窗口预检 + 历史卫生 + 配对修复 + 压缩重试 + 超时快照兜底
少任何一个环节,长会话就会出问题。
模块一 窗口预检(运行前拦截)
context-window-guard.ts 定义了两条红线:
CONTEXT_WINDOW_HARD_MIN_TOKENS = 16000CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32000
实际流程:
resolveContextWindowInfo(...)决定窗口来源:
modelsConfigmodelagentContextTokens(配置硬上限)default
evaluateContextWindowGuard(...)给出shouldWarn/shouldBlock- 在
run.ts中,如果shouldBlock=true,直接抛错,不进入昂贵调用。
这一步是“防止明知会炸还去调用模型”。
模块二 历史截断(按会话类型动态限额)
history.ts 不是固定截断,而是“按会话类型解析”:
getHistoryLimitFromSessionKey(...)从 sessionKey 识别:
- dm/direct:走
dmHistoryLimit或dms.<user>.historyLimit - channel/group:走
historyLimit
limitHistoryTurns(...)只保留最近 N 个 user turn 及其关联上下文。
这比“按消息条数”更靠谱,因为 turn 更接近真实对话语义。
模块三 转录卫生(防 provider 400)
run/attempt.ts 的顺序非常关键:
sanitizeSessionHistory(...)validateGeminiTurns(...) / validateAnthropicTurns(...)limitHistoryTurns(...)sanitizeToolUseResultPairing(...)
为什么第 4 步要在截断后再跑一次:
limitHistoryTurns(...) 可能把某个 tool_use 删掉,却留下 tool_result,变成孤儿消息,Anthropic 类接口会直接 400。
session-transcript-repair.ts 会做三件事:
- 移动错位
toolResult到对应 assistant toolCall 后面 - 删除重复
toolResult - 对缺失的结果插入 synthetic error result(防断链)
模块四 压缩重试状态机
subscribeEmbeddedPiSession(...) 内部维护:
compactionInFlightpendingCompactionRetrycompactionRetryPromise
waitForCompactionRetry() 的语义是:
- 如果还在压缩或待重试,等待 promise
- 如果已
unsubscribe,抛AbortError - 否则立即返回
这保证 run 不会在压缩还没结束时“误判完成”。
模块五 压缩超时快照选择
run/compaction-timeout.ts:
shouldFlagCompactionTimeout(...):只有“超时 + 压缩相关状态”为真才算 compaction timeoutselectCompactionTimeoutSnapshot(...):优先pre-compaction快照;没有就回当前快照
这能避免拿到“半压缩”状态的脏消息。
模块六 上下文溢出的恢复路径
run.ts 在检测到 overflow 时按顺序恢复:
- 尝试
compactEmbeddedPiSessionDirect(...) - 若仍失败,尝试
truncateOversizedToolResultsInSession(...) - 超过上限后返回可读错误,提示用户 reset 或换大窗口模型
这是实战里非常关键的“降损链路”。
最小复刻骨架
ts
async function prepare(messages: AgentMessage[], sessionKey: string, cfg: Config) {
const cleaned = await sanitizeSessionHistory({ messages, policy: resolvePolicy() });
const validated = validateProviderTurns(cleaned);
const limited = limitHistoryTurns(validated, getHistoryLimitFromSessionKey(sessionKey, cfg));
return sanitizeToolUseResultPairing(limited);
}
async function runTurnWithCompactionWait(session: Session, prompt: string) {
const sub = subscribe(session);
let snapshotBefore = session.messages.slice();
try {
await session.prompt(prompt);
await sub.waitForCompactionRetry();
return session.messages.slice();
} catch (err) {
return selectCompactionTimeoutSnapshot({
timedOutDuringCompaction: isCompactionTimeout(err),
preCompactionSnapshot: snapshotBefore,
preCompactionSessionId: session.sessionId,
currentSnapshot: session.messages.slice(),
currentSessionId: session.sessionId,
}).messagesSnapshot;
} finally {
sub.unsubscribe();
}
}自检清单
- 长会话 100 轮后是否仍能稳定回复。
- 是否不存在孤儿
toolResult。 - 压缩期间超时是否仍能返回一致快照。
- 窗口过小模型是否在 run 前就被拦截。
- overflow 后是否按“压缩 -> 截断 -> 报错”顺序执行。