长任务 Agent 最容易翻车的不是模型不够聪明,而是上下文越来越脏:工具结果、历史消息、检索片段和临时决策混在一起,最后关键证据反而被挤出去。本文把 context engineering 落到一个可实现的上下文账本:每条上下文都有来源、价值、保质期和驱逐策略。
如果你做过长任务 Agent,大概率见过这种事故:前 10 轮还很正常,到了第 30 轮开始答非所问,第 50 轮突然忘掉用户最早给的约束,第 70 轮把一个已经失败的工具结果当成事实继续推理。
很多团队第一反应是换更大的上下文窗口。窗口从 32K 换到 128K,再换到更长,短期看确实缓解了问题,但根因没有变。Agent 的上下文不是一个无限增长的聊天记录,它更像运行时内存、证据仓库和任务日志的混合体。只要没有生命周期管理,再大的窗口也会被低价值工具输出、重复检索片段、旧计划和临时调试信息塞满。
这篇文章讲一个我认为更接近生产形态的做法:给 Agent 做一套"上下文账本"。账本不只是把历史消息存起来,而是给每一段进入模型窗口的信息记录来源、价值、风险、保质期、引用关系和驱逐策略。模型每次调用前,不是简单 append 最近 N 条消息,而是从账本里挑出当前任务真正需要的上下文。
一、为什么 128K 窗口也会变成垃圾场
过去一年,context engineering 这个词越来越热。Sourcegraph 在 2026 年的实践指南里把它定义为:设计模型每次推理能看到什么,包括系统提示、用户输入、检索文档、对话历史、工具定义和长期记忆。LangChain 的文章则把策略拆成 write、select、compress、isolate:写入上下文、选择上下文、压缩上下文、隔离上下文。
这些说法背后其实是同一个事实:Agent 的质量不只由模型决定,也由上下文流水线决定。
一个典型 Agent loop 里,上下文来源至少有六类:
- 用户原始目标和后续修正
- 系统约束、输出格式、工具说明
- 工具调用参数和工具返回结果
- 检索到的文档、网页、代码片段
- Agent 自己的计划、反思、错误恢复记录
- 跨会话记忆、项目规范、用户偏好
最危险的是第三类和第四类。工具结果很容易又长又脏。一个 grep 可能返回 500 行,一个 MCP 工具可能吐出整份 JSON,一个网页抓取可能带着导航栏、广告和重复段落。检索系统也类似,top-k 只是相关性的粗筛,并不保证这些内容都值得进入当前模型调用。
更麻烦的是,这些内容一旦进入聊天历史,很多框架会默认把它们继续带到下一轮。于是上下文窗口从"帮助模型完成任务的证据"慢慢变成"所有曾经发生过的东西"。这不是记忆,这是污染。
二、上下文账本的核心思路
上下文账本解决的是一个具体问题:每条上下文进入模型前,都必须回答五个问题。
- 它从哪里来?
- 它现在有什么用?
- 它什么时候过期?
- 它的风险是什么?
- 它应该原文保留、摘要保留,还是只保留引用?
可以把账本想成一张表:
| 字段 | 含义 | 示例 |
|---|---|---|
| id | 上下文片段 ID | tool:search:17 |
| source | 来源类型 | user / tool / retrieval / memory / plan |
| content | 原始内容或摘要 | "接口返回 429,Retry-After=12" |
| tokens | 估算 token 数 | 58 |
| value | 当前价值分 | 0-100 |
| ttl | 保质期 | 3 turns / until task done |
| risk | 风险标签 | untrusted / stale / pii / failed-tool |
| pinned | 是否不可驱逐 | 用户目标、硬约束 |
| refs | 引用关系 | 依赖哪个工具调用、哪条用户约束 |
| mode | 进入窗口方式 | full / summary / pointer / drop |
关键点是:账本不是总结器。总结只是账本的一种动作。账本的第一职责是治理上下文生命周期。
比如一次代码排障任务中,用户目标和仓库约束应该 pinned;最近一次失败的测试输出可以 full 保留两轮;三十分钟前的依赖安装日志只需要 summary;一个失败的网页抓取结果应该带上 failed-tool 标签,并且默认不再进入窗口;大段文档可以只放 pointer,必要时再读。
三、一个可运行的小实验
下面用一个简化实验说明为什么账本比 naive append 更稳。这个实验不调用任何大模型,也不宣称线上性能,只模拟上下文组装策略。我们构造 60 条事件,其中包含用户硬约束、工具输出、失败结果、检索片段和关键证据。然后比较两种策略:
- naive append:按时间倒序塞满预算
- ledger select:先保 pinned,再按价值、风险和 TTL 选择
你可以直接复制运行。
js
// context-ledger-demo.js
const BUDGET = 900;
function estimateTokens(text) {
// 中文粗略按 1.5 字/token,英文按 4 chars/token。真实生产可接 tiktoken 或供应商 tokenizer。
const cjk = (text.match(/[\u4e00-\u9fff]/g) || []).length;
const other = text.length - cjk;
return Math.ceil(cjk / 1.5 + other / 4);
}
function makeEvent(i) {
if (i === 0) return {
id: 'user-goal', source: 'user', pinned: true, ttl: 999, value: 100, risk: [],
content: '用户目标:修复支付回调偶发重复入账;禁止修改数据库 schema;必须给出回滚方案。'
};
if (i === 19) return {
id: 'key-evidence', source: 'tool', pinned: false, ttl: 20, value: 95, risk: [],
content: '关键证据:callback_id 在 Redis 去重键过期后仍可能被消息队列重投,日志显示 duplicate_charge=true。'
};
if (i % 11 === 0) return {
id: `failed-tool-${i}`, source: 'tool', pinned: false, ttl: 2, value: 20, risk: ['failed-tool'],
content: '工具失败:网页抓取超时,返回了一大段无关 HTML 和导航栏。'.repeat(20)
};
if (i % 5 === 0) return {
id: `retrieval-${i}`, source: 'retrieval', pinned: false, ttl: 5, value: 45, risk: ['untrusted'],
content: ('检索片段:某博客讨论幂等性、重试、消息队列,但没有直接覆盖当前支付回调实现。').repeat(8)
};
return {
id: `note-${i}`, source: 'plan', pinned: false, ttl: 4, value: 30 + (i % 7) * 5, risk: [],
content: `第${i}轮计划和中间观察:继续检查日志、配置和队列消费路径。`
};
}
const events = Array.from({ length: 60 }, (_, i) => {
const e = makeEvent(i);
e.turn = i;
e.tokens = estimateTokens(e.content);
return e;
});
function naiveAppend(events, budget) {
const picked = [];
let used = 0;
for (const e of [...events].reverse()) {
if (used + e.tokens > budget) continue;
picked.push(e);
used += e.tokens;
}
return { picked, used };
}
function ledgerSelect(events, budget, currentTurn) {
const scored = events.map(e => {
const age = currentTurn - e.turn;
const expired = !e.pinned && age > e.ttl;
const riskPenalty = e.risk.includes('failed-tool') ? 50 : e.risk.includes('untrusted') ? 15 : 0;
const recencyBonus = Math.max(0, 10 - age);
return { ...e, score: expired ? -999 : e.value + recencyBonus - riskPenalty };
});
const pinned = scored.filter(e => e.pinned);
const rest = scored.filter(e => !e.pinned && e.score > 0).sort((a, b) => b.score - a.score);
const picked = [];
let used = 0;
for (const e of [...pinned, ...rest]) {
const content = e.tokens > 180 && !e.pinned
? `[summary] ${e.content.slice(0, 120)}...`
: e.content;
const tokens = estimateTokens(content);
if (used + tokens > budget) continue;
picked.push({ ...e, content, tokens });
used += tokens;
}
return { picked, used };
}
for (const [name, result] of [['naive', naiveAppend(events, BUDGET)], ['ledger', ledgerSelect(events, BUDGET, 59)]]) {
const ids = result.picked.map(e => e.id);
console.log(`\n== ${name} ==`);
console.log('tokens:', result.used);
console.log('contains user goal:', ids.includes('user-goal'));
console.log('contains key evidence:', ids.includes('key-evidence'));
console.log('failed tool chunks:', result.picked.filter(e => e.risk.includes('failed-tool')).length);
console.log('picked:', ids.join(', '));
}
在我的本地运行中,naive append 更容易保留最近的无关片段,却丢掉最早的用户硬约束;ledger select 会优先保留用户目标和关键证据,并把失败工具的大块输出排除。你运行时具体数字可能略有变化,因为 token 估算很粗糙,但方向很稳定:上下文预算应该分配给"当前仍有决策价值的信息",不是"最近发生的信息"。
四、生产版账本怎么设计
生产实现不需要一开始很复杂。建议从四个模块做起。
1. ContextWriter:所有上下文统一入账
不要让工具结果直接进入 message history。工具调用完成后,先进入 ContextWriter。Writer 做三件事:
- 估算 token 和内容类型
- 打风险标签,比如 untrusted、pii、failed-tool、stale-candidate
- 决定默认 mode,是 full、summary、pointer 还是 drop
伪代码如下:
ts
type ContextItem = {
id: string;
source: 'user' | 'tool' | 'retrieval' | 'memory' | 'plan';
content: string;
summary?: string;
tokens: number;
value: number;
ttlTurns: number;
risk: string[];
pinned: boolean;
createdTurn: number;
refs: string[];
mode: 'full' | 'summary' | 'pointer' | 'drop';
};
function writeToolResult(result: ToolResult, turn: number): ContextItem {
const failed = result.status !== 'ok';
const tooLarge = estimateTokens(result.text) > 800;
return {
id: `tool:${result.callId}`,
source: 'tool',
content: result.text,
summary: tooLarge ? summarizeDeterministically(result.text) : undefined,
tokens: estimateTokens(result.text),
value: failed ? 10 : inferValue(result),
ttlTurns: failed ? 1 : 5,
risk: [failed && 'failed-tool', result.untrusted && 'untrusted'].filter(Boolean) as string[],
pinned: false,
createdTurn: turn,
refs: result.refs,
mode: failed ? 'pointer' : tooLarge ? 'summary' : 'full'
};
}
这里的重点不是 summarizeDeterministically 写得多漂亮,而是工具结果不再自动拥有"永久进入上下文"的权利。
2. ContextSelector:每轮调用前重新组装
Selector 输入当前任务状态、预算和账本,输出本轮模型调用的上下文。
一个可落地的预算分配是:
| 区域 | 默认预算 | 内容 |
|---|---|---|
| Hard constraints | 10% | 用户目标、禁区、输出格式 |
| Current task state | 15% | 当前计划、下一步、未解决问题 |
| Evidence | 35% | 关键工具结果、检索证据、错误日志 |
| Recent dialogue | 20% | 最近用户修正和模型响应 |
| Tool schema | 10% | 本轮可用工具,不是全量工具 |
| Reserve | 10% | 给模型输出和临时插入留余量 |
这个比例不是固定真理,但它能逼团队问一个关键问题:为什么某段内容值得占预算?
3. ContextCompressor:压缩必须可追溯
很多 Agent 的总结会出问题,是因为总结后丢了来源。压缩后的内容至少要带三个东西:
- 原始 item id 列表
- 被保留的事实
- 被丢弃的信息类型
比如:
json
{
"summary_id": "sum:tool:17-24",
"source_ids": ["tool:17", "tool:19", "retrieval:22"],
"kept_facts": [
"Redis 去重键 TTL 为 10 分钟",
"队列重投可能晚于 10 分钟",
"日志出现 duplicate_charge=true"
],
"dropped": ["重复日志行", "无关 HTML", "已失败的抓取结果"],
"risk": ["derived-summary"]
}
这样做的好处是,后续如果模型基于 summary 做出关键决策,可以反查原文,而不是相信一段无法审计的"历史总结"。
4. ContextAuditor:把上下文选择变成可观测事件
上线后不要只看模型输出。你需要记录每次模型调用的上下文装配摘要:
- 本轮总 token 预算和实际使用
- 被选中的 item id
- 被驱逐的 top item 及原因
- pinned 内容是否全部存在
- 高风险内容是否进入窗口
- summary 是否引用了原始证据
这类日志不应该包含用户敏感原文,可以只存 id、hash、标签和 token 数。真正要排障时,再按权限读取原始账本。
五、四种策略对比
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 最近 N 条消息 | 实现简单 | 容易丢早期硬约束,保留大量噪声 | 短聊天、客服 FAQ |
| 全量总结 | 省 token | 容易丢来源,摘要错误难追责 | 低风险长对话 |
| RAG top-k | 对外部知识有效 | 不管理工具结果和任务历史 | 文档问答 |
| 上下文账本 | 可追溯、可驱逐、可观测 | 实现成本更高 | 长任务 Agent、代码 Agent、MCP 工具密集应用 |
我的建议很直接:只要你的 Agent 会连续跑超过 20 轮,或者会调用会返回大块内容的工具,就不要再只用"最近 N 条消息"了。它不是工程方案,只是 demo 默认值。
六、几个最容易踩的坑
坑 1:把 failed tool result 当正常证据
失败工具结果经常包含错误页、半截 JSON、空数组或者重试提示。它们可以作为"工具失败"这个事实进入上下文,但不能作为业务事实进入上下文。账本里一定要给 failed-tool 单独标签,并默认 pointer 或 drop。
坑 2:工具定义全量暴露
很多团队给 Agent 一次性挂几十个工具,结果每轮调用都把工具 schema 塞进窗口。更好的做法是按任务阶段暴露工具:搜索阶段只给 search/read,修改阶段再给 edit/test,发布阶段再给 publish。工具本身也是上下文,也要预算。
坑 3:summary 没有 source id
没有 source id 的 summary 很快会变成"模型说模型说过"。排障时你不知道它来自哪次工具调用,也不知道是否已经过期。压缩必须保留引用链。
坑 4:pinned 太多
有些团队把所有系统提示、所有安全规则、所有用户偏好都 pinned,最后 pinned 区域占掉 70% 预算。pinned 只应该放真正不可违背的硬约束。其他内容可以高权重,但不该永久免死。
坑 5:只做 token 裁剪,不做价值裁剪
按长度裁剪只能解决"塞不下",不能解决"该塞什么"。价值分至少要考虑任务相关性、时间、来源可信度、是否被后续证据引用。
七、落地清单
如果你准备把上下文账本加进现有 Agent,我建议按这个顺序做:
- 先把工具结果从 message history 里拆出来,统一写入账本。
- 给用户目标、禁区和验收标准加 pinned 标签。
- 给 failed-tool、untrusted、pii、stale 四类风险标签。
- 每轮模型调用前输出 context manifest,不要只输出最终 prompt。
- 对超过阈值的工具结果默认 summary 或 pointer。
- summary 必须包含 source_ids 和 dropped 信息。
- 记录驱逐原因:expired、low-value、over-budget、risky、duplicated。
- 用回放测试验证:同一任务在不同窗口预算下,关键证据是否仍能进入上下文。
- 对外部检索内容设置 TTL,不要让昨天的搜索结果永久污染今天的决策。
- 给高风险动作设置 preflight:执行前强制检查 hard constraints 是否仍在上下文里。
八、一个更现实的工程判断
上下文账本听起来像基础设施,但它不一定要第一天就做成平台。最小可行版本甚至可以只是一个 JSONL 文件:每次工具调用写一行,每次模型调用写一份 manifest。等你开始遇到"Agent 为什么忘了约束""它为什么引用了失败结果""为什么越跑越慢"这些问题,再把 selector、compressor 和 auditor 独立出来。
真正的分水岭不是你有没有 128K 窗口,而是你能不能回答这三个问题:
- 这次模型调用看到的关键证据是什么?
- 哪些内容被丢弃了,为什么?
- 如果模型做错了,我们能否回放当时的上下文?
如果答不上来,你的 Agent 其实还停留在"把聊天记录越堆越高"的阶段。
Agent 工程接下来会越来越像传统后端工程:有内存管理,有审计日志,有生命周期,有隔离边界。上下文账本只是把这些老问题搬到了模型调用前。别等线上事故发生后才发现,真正拖垮 Agent 的不是模型能力,而是你给它看的那堆上下文,早就变成了垃圾场。