Agent 上下文账本工程:别让工具结果把 128K 窗口塞成垃圾场

长任务 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 只是相关性的粗筛,并不保证这些内容都值得进入当前模型调用。

更麻烦的是,这些内容一旦进入聊天历史,很多框架会默认把它们继续带到下一轮。于是上下文窗口从"帮助模型完成任务的证据"慢慢变成"所有曾经发生过的东西"。这不是记忆,这是污染。

二、上下文账本的核心思路

上下文账本解决的是一个具体问题:每条上下文进入模型前,都必须回答五个问题。

  1. 它从哪里来?
  2. 它现在有什么用?
  3. 它什么时候过期?
  4. 它的风险是什么?
  5. 它应该原文保留、摘要保留,还是只保留引用?

可以把账本想成一张表:

字段 含义 示例
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,我建议按这个顺序做:

  1. 先把工具结果从 message history 里拆出来,统一写入账本。
  2. 给用户目标、禁区和验收标准加 pinned 标签。
  3. 给 failed-tool、untrusted、pii、stale 四类风险标签。
  4. 每轮模型调用前输出 context manifest,不要只输出最终 prompt。
  5. 对超过阈值的工具结果默认 summary 或 pointer。
  6. summary 必须包含 source_ids 和 dropped 信息。
  7. 记录驱逐原因:expired、low-value、over-budget、risky、duplicated。
  8. 用回放测试验证:同一任务在不同窗口预算下,关键证据是否仍能进入上下文。
  9. 对外部检索内容设置 TTL,不要让昨天的搜索结果永久污染今天的决策。
  10. 给高风险动作设置 preflight:执行前强制检查 hard constraints 是否仍在上下文里。

八、一个更现实的工程判断

上下文账本听起来像基础设施,但它不一定要第一天就做成平台。最小可行版本甚至可以只是一个 JSONL 文件:每次工具调用写一行,每次模型调用写一份 manifest。等你开始遇到"Agent 为什么忘了约束""它为什么引用了失败结果""为什么越跑越慢"这些问题,再把 selector、compressor 和 auditor 独立出来。

真正的分水岭不是你有没有 128K 窗口,而是你能不能回答这三个问题:

  • 这次模型调用看到的关键证据是什么?
  • 哪些内容被丢弃了,为什么?
  • 如果模型做错了,我们能否回放当时的上下文?

如果答不上来,你的 Agent 其实还停留在"把聊天记录越堆越高"的阶段。

Agent 工程接下来会越来越像传统后端工程:有内存管理,有审计日志,有生命周期,有隔离边界。上下文账本只是把这些老问题搬到了模型调用前。别等线上事故发生后才发现,真正拖垮 Agent 的不是模型能力,而是你给它看的那堆上下文,早就变成了垃圾场。

相关推荐
卷无止境1 小时前
FastAPI 的 Request 类装了什么
后端·python
程序员爱钓鱼1 小时前
Rust 方法 Method详解:self、方法调用与API设计
后端·面试·rust
橙子家1 小时前
MSDTC(微软分布式事务处理协调器)对系统稳定性的影响
后端
卡布叻_星星1 小时前
后端架构笔记之Maven多模块与微服务
笔记·架构
程序员黑豆1 小时前
Java正则表达式详解
java·前端·ai编程
程序员爱钓鱼1 小时前
Go break、continue、goto 详解
后端·面试·go
凤山老林2 小时前
复杂检索引擎落地:Spring Boot + Elasticsearch 数据同步与高阶查询实战
spring boot·后端·elasticsearch
笃行35010 小时前
SQLServer数据迁移之后,那张报表还能不能秒出
后端
特立独行的猫a10 小时前
一切皆插件:DeepSeek Harness 的架构哲学,以及与主流 Agent 的对比
人工智能·架构·agent·deepseek·harness