第 9 章 · 上下文工程

Harness 锚点|判别清单第 6 项「上下文工程」——长任务如何压缩、保留关键信息。

一场跨了三个时区的排查

那个做跨境电商的卖家,这次遇到的是个磨人的活儿:欧洲站有一批订单在清关环节卡住了,货代、海外仓、平台客服各执一词。他把这摊子事丢给了 Agent,让它顺着聊天记录、物流单号、平台工单一路查下去,理清到底卡在哪。

Agent 干得很卖力。它先读了三十几封往来邮件,又调物流接口拉了十几个包裹的轨迹,再翻平台工单系统调出争议详情——每一步的原始返回都老老实实塞回了对话里。查到第四十多轮的时候,问题来了:Agent 忽然开始“忘事”。它把最开头卖家交代的“只查德国仓那一批”抛到了脑后,跑去分析法国仓的订单;你提醒它一句,它又绕回来,可没过几轮,那份最关键的清关税则说明——明明二十轮之前它自己查到过——它却像从没见过一样,又去查了一遍。

它不是变笨了,是装不下了。几十轮对话、十几份原始物流报文、上千行工单详情,早就把模型的上下文塞得满满当当。有用的没用的挤在一起,真正关键的那几条线索反而被淹没了。这一章要解决的,正是这个问题:一个 Agent 跑长任务时,怎么在有限的空间里,始终“记得住重点”。这门手艺,叫上下文工程(Context Engineering)

上下文窗口:一块会用完的白板

先说清楚那块“装不下”的空间到底是什么。模型每次生成回复,都要把当前对话的全部内容一次性读进去——系统提示、历史消息、工具返回,无一例外。这块能一次读进去的最大容量,就是上下文窗口(context window),以 token(词元) 为单位计量。

哪怕今天的模型窗口动辄十几万、几十万 token,它也终究是有限的。而一个 Agent 在长任务里做的事,恰恰是不断往里塞东西

  • 系统提示、工具说明——开局就占一块,而且每轮都在。
  • 几十轮的对话历史——用户问一句、模型答一段、工具回一堆,越滚越长。
  • 每次工具调用的完整原始返回——一次物流查询几百行 JSON,全塞进去。

于是白板越写越满,最终撞上两堵墙:

  1. 溢出(overflow):内容超过窗口上限,塞不进去。轻则运行时报错中断,重则悄悄丢掉最早的消息——而任务目标往往就写在最开头。
  2. 又贵、又慢、又笨:就算还没溢出,窗口里的 token 越多,每次调用付的钱越多、等的时间越长;更麻烦的是模型的注意力会被稀释

第三点尤其值得单拎出来讲,它有个专门的名字。

“迷失在中间”:为什么塞得下不等于用得好

研究者发现了一个反直觉的现象:当你把大量信息塞进上下文窗口,模型对开头结尾的内容记得清楚,唯独对正中间那一大段最容易忽略、答错、漏掉。这个现象被称为“迷失在中间”(lost in the middle)

打个比方:这就像你让人读一份五十页的报告再回答问题,他大概率记得第一页的结论和最后一页的建议,中间第二十几页那个关键数据,很可能就滑过去了。模型也一样——把信息塞进窗口,不等于模型真的用得上它。

回到开头那个场景:Agent 之所以把二十轮前查到的清关税则又查了一遍,不是因为那条信息不在上下文里,而是因为它被埋在了几十轮对话的“正中间”,被前后大量的物流报文淹没了。窗口没满,但那条关键信息事实上已经“迷失”了。

这就带出了上下文工程的核心判断:长上下文不是免费的。你往窗口里多塞一行没用的工具输出,付出的不只是那几个 token 的钱,还稀释了真正重要信息的存在感。所以我们要主动管理上下文——明确地决定:什么留、什么压、什么扔。

压缩,而不是截断

管理上下文最粗暴的做法叫截断(truncation):窗口快满了,就直接从最早的消息开始砍,砍到装得下为止。

截断的问题显而易见——最早的消息往往最重要。开头那位卖家交代的“只查德国仓那一批”,恰恰是最先进对话、最先被砍掉的。于是 Agent 跑着跑着就忘了初衷,跑去分析法国仓。截断省事,但它是“按时间”删,而不是“按价值”删,砍掉的常常正是你最需要留的。

更聪明的做法是压缩(compaction):当上下文接近上限时,不是硬砍,而是把一段较早的对话总结(summarize)成一段简短的摘要,再用这段摘要替换掉原始的长内容。几百行物流报文,压成一句“德国仓 12 个包裹已到保税区,其中 3 个因税则归类争议被扣”——信息的要点留下了,占的 token 却少了一个数量级。

好的压缩,守住三条原则:

  • 保留高价值信息:任务目标、关键决策、已确认的事实。这些是后续每一步都要依赖的,一条都不能丢。
  • 压缩低价值信息:冗长的工具原始输出、已经完成的中间步骤。这些占地方最多、复用价值最低,是压缩的首要对象。
  • 摘要必须“可继续”(resumable):压缩之后,Agent 读着这段摘要还能无缝接着往下干,不能因为丢了某条线索而卡壳或重复劳动。

一句话:截断是按位置删,压缩是按价值删。好的压缩,让 Agent“记得住重点、忘得掉细节”——这恰恰是人类处理长期工作时的方式。

上下文里到底装了什么

在动手压缩之前,得先看清楚:一个 Agent 的上下文,远不只是“对话历史”这一样。它通常由这几块拼成:

  • 系统提示(system prompt):角色设定、行为规则、可用工具的说明。它决定了 Agent“是谁、能干什么、守什么规矩”(第 13 章细讲)。
  • 对话历史(conversation history):用户消息 + 模型回复 + 工具结果,一轮一轮累积。这是长任务里增长最快的部分。
  • 动态注入(dynamic injection):当前时间、当前加载的技能(Skills,第 7 章)、检索(RAG)回来的知识片段——这些是运行时临时拼进去的。
  • 记忆(memory):跨会话保留的长期信息(下一章的主题)。

上下文工程,本质上就是精心编排这几块内容,在有限的窗口里塞进“当下最有用的组合”。压缩,只是这门手艺里最关键的一招——因为对话历史增长最快,也最先撑爆窗口。

想直观看到这几块各占多少,pi 的界面(pi-app)顶栏就实时显示着上下文占用、当前花费、以及是否触发了压缩——这块白板还剩多少空、烧了多少钱,一眼可见。

企业上下文不只是 Prompt

进入真实经营流程后,上下文不再只是“系统提示 + 聊天记录”。Agent 要判断一笔退款、一次补货或一个客户跟进动作,必须知道当前业务状态、规则来源、数据时效和调用者权限。把这些都复制进 Prompt 既昂贵又危险;更合理的方式,是由 Harness 在运行时按任务装配必要、可信、可追溯的上下文。

上下文类型典型来源装配时要回答的问题
业务状态ERP、OMS、CRM、工单系统当前订单、客户、库存或任务处于什么状态?
经营数据数据仓库、BI、实时指标指标口径是什么,数据更新到什么时候?
规则与知识SOP、政策、产品资料、合同规则由谁维护,当前生效的是哪个版本?
历史案例历史工单、审批、运营记录哪些案例可复用,哪些只是过时经验?
身份与权限IAM、组织架构、租户配置调用者是谁,能看什么、能批准什么、能执行什么?

给上下文建立契约

每类上下文至少应携带六个字段:source(来源)、owner(责任人)、version(版本)、freshness(时效)、permission(访问边界)和 expiresAt(失效时间)。模型看到的是内容,Harness 还必须保留这些元数据,才能在回答出错时追溯“用了哪份数据、当时是否过期、为什么允许访问”。

只连接当前闭环必需的上下文。 先把一个真实流程跑通,再沉淀复用接口;不要先建一个包罗万象的数据平台,最后才寻找 Agent 能解决的问题。

用 pi 实现:Compaction

pi 内置了一套 Compaction 机制。当对话增长到接近窗口阈值时,它会自动把较早的部分总结压缩,为新内容腾出空间——这是开箱即用的默认行为,你什么都不配也能享受到。

但真正体现 pi 设计取向的,是压缩策略本身可以被扩展定制。回顾第 8 章讲的扩展(Extensions)机制:扩展是一个 TS 模块,能订阅 Agent 的生命周期事件。压缩前的那一刻,就有一个专门的事件——beforeCompact。订阅它,你就能用自己的逻辑决定“这次压缩,留什么、丢什么”:

export default function customCompaction(pi) {
  pi.on("beforeCompact", async (messages, ctx) => {
    // 用你自己的方式总结旧消息(概念示意)
    const summary = await ctx.summarize(messages, {
      keep: ["任务目标", "关键决策", "已确认的事实"],
      drop: ["冗长的工具输出", "已完成的中间步骤"],
    });
    return { replaceWith: summary };
  });
}

这套设计的价值在于:pi 既给了你一个合理的默认压缩,又把方向盘留在你手里。不同场景要留的东西天差地别——

// 不同 Agent,压缩时想守住的"高价值信息"不一样(概念示意)
const KEEP_PROFILES = {
  logistics: ["涉及的仓库/单号", "已确认的清关状态", "待跟进的争议"],
  coding:    ["改过哪些文件", "已通过的测试", "未解决的报错"],
  research:  ["已验证的关键结论", "数据来源", "尚存的疑问"],
};

一个查物流的 Agent 想牢牢记住“哪些单号、卡在哪一步”;一个写代码的 Agent 想记住“改过哪些文件、哪些测试还红着”;一个做调研的 Agent 想记住“已经验证过的结论和它的出处”。同一套 beforeCompact 钩子,套不同的 keep 清单,就成了不同任务的“专属记忆术”。

在 pi 里,压缩不是事后打的补丁,而是 harness(运行时框架)的一等公民——它有自己的文档、自己的生命周期事件、自己的可扩展入口。

动手看看

想把上面这套机制从“概念”落到“眼见为实”,可以顺着 pi 自带的东西走一遍:

先找到 pi 文档里的 compaction.md,读一读它默认是怎么判断“该压缩了”、默认保留和丢弃的又是哪些内容。对照上一节的代码,看看默认策略和你能定制的部分,边界划在哪里。

再去翻会话目录 ~/.pi/agent/sessions/——pi 会把每次会话结构化地存在这里。挑一个跑过长任务的会话,观察压缩前后消息是怎么变的:哪几条被合并成了摘要、摘要里留下了什么。

最后,如果手边有 pi-app,盯着顶栏那个上下文占用的数字,跑一个会触发压缩的长任务,亲眼看它在压缩发生的那一刻“唰”地降下来——那一下,就是这一章讲的全部内容在真实发生。

实战中的几个坑

坑一:摘要把关键线索也压没了。

  • 现象:压缩之后 Agent 突然“失忆”,重复去查早已查过的信息,或忘了任务的某个约束。
  • 原因:摘要策略太激进,把本该保留的高价值信息(任务目标、已确认的事实)一并当细节丢了。
  • 对策:明确列出 keep 清单(目标、关键决策、待办),让压缩“按价值删”,而不是笼统地缩短。

坑二:该压缩时没压,直接撞窗口溢出。

  • 现象:长任务跑到一半突然报错中断,或模型回复明显开始丢失早期上下文。
  • 原因:压缩阈值设得太贴近窗口上限,一次超长的工具返回就把剩余空间一口吃满,来不及压缩。
  • 对策:把触发阈值留出余量(如窗口的 70%~80% 就开始压),别等满了才动手。

坑三:工具原始输出不做处理就回填。

  • 现象:上下文膨胀得异常快,压缩频繁触发,费用居高不下。
  • 原因:每次工具调用把上千行 JSON、整页 HTML 原样塞回对话,压缩其实是在为“本不该进来的垃圾”买单。
  • 对策:在工具那一层就先截断/摘要(呼应第 4 章),只回填必要字段,从源头减少要压缩的量。

坑四:关键信息埋在中间被模型忽略。

  • 现象:某条信息明明在上下文里,模型却像没看见,答非所问。
  • 原因:“迷失在中间”——它被埋在几十轮对话的正中段,前后都是大段低价值内容。
  • 对策:把最关键的约束和事实,通过压缩后的摘要“提”到靠前或靠后的位置,别让它长期滞留在中间地带。

对比:其他框架怎么管上下文

“窗口会满”是每个框架都躲不掉的问题,但各家把主动权交给你的多少不同:

框架上下文怎么管特点
LangGraph上下文即图里流动的 state,压不压、怎么压全靠你在节点里写逻辑;配 checkpointer 持久化状态,长历史多靠自定义的裁剪/摘要节点最灵活,也最需要自己动手
CrewAI默认帮你管每个任务的上下文,可开启 memory 让任务间共享要点;压缩偏封装省心,但可调的旋钮少
PydanticAI把对话建模成消息历史(message history),用 history_processors 在每轮请求前处理消息(截断/摘要)接口清晰,压缩策略需自己实现
Agno把上下文 / 会话摘要 / 知识做成显式组件,可配置自动生成会话摘要、注入哪些历史偏“配置即用”
pi内置 Compaction,接近上限时把旧对话总结成摘要,保留高价值信息;并可通过 beforeCompact 扩展自定义压缩策略默认合理,又留足定制入口

不管哪个框架,核心矛盾完全一样:窗口有限,信息无限。差别只在于——压缩这件事,是框架替你默默做了(你插不上手),还是留个接口让你自己定夺。上下文工程,就是在这场“留什么、扔什么”的取舍里,把有限的窗口用出最大价值。

小结

  1. 上下文窗口(context window) 是有限的,一个 Agent 在长任务里不断累积历史,迟早会溢出,或即使没溢出也会变贵、变慢、变笨。
  2. 长上下文有个隐形代价——“迷失在中间”(lost in the middle):信息塞进窗口不等于模型用得上它,埋在中段的关键线索最容易被忽略。
  3. 压缩(compaction)优于截断(truncation):截断按位置删、常砍掉最重要的开头;压缩按价值删,把旧对话总结成“可继续”的摘要,保留目标与事实、丢弃冗长细节。
  4. 上下文不只是对话历史,还包括系统提示、动态注入、记忆等——上下文工程就是精心编排这几块,在有限窗口里塞进最有用的组合。
  5. pi 把 Compaction 做成 harness 的一等公民:默认自动压缩,又通过 beforeCompact 扩展点允许你按场景自定义压缩策略

压缩解决的是“单次会话之内”怎么记得住重点。可如果这个卖家明天又来问同一批货,Agent 该不该记得今天查过的一切?这就超出了单个上下文窗口的范畴,进入了记忆架构的世界——那是下一章的主题。