前言

2023 年我写《ChatGPT API 之初体验》那会儿,圈内讨论的还全是 Prompt 工程:怎么写一句”你是一位资深专家”、怎么加一句”让我们一步一步思考”、怎么用 few-shot 举例子。那时候模型窗口只有 4K、8K token,一次能塞进去的信息极其有限,”把一句话说到点子上”就是全部功夫。

三年过去,情况彻底变了。2026 年的模型,200K 上下文是标配,长上下文模型普遍到了百万级 token。窗口不再稀缺,新的问题反而冒出来了:上下文里塞的东西越多,模型表现并不见得更好,甚至越来越笨。

于是行业里出现了一个新词:Context Engineering(上下文工程)。它回答的问题不再是”这句话怎么写”,而是”在每次模型调用之前,往上下文里放什么、不放什么、以什么结构放”。最近半年我在一个多轮客服 Agent 项目里被这个问题反复毒打,这篇文章就把踩过的坑和沉淀的做法一次性聊清楚。

1. 从 Prompt 工程到 Context 工程

1.1 窗口变大之后,问题变了

Prompt 工程时代的假设是:上下文很小,所以每一句话都要精雕细琢。而上下文工程的假设是:上下文很大,大到你可以把整个”工作环境”都搬进去——文档、工具结果、用户偏好、历史对话——但大不等于可以乱放。

打个比方:Prompt 工程像是琢磨”这句话怎么说好听”,Context 工程像是给一位新同事布置办公桌——桌上放哪些资料、什么资料放在最顺手的位置、过期的文件要不要扔掉。桌子乱七八糟堆满东西,再聪明的同事也会效率骤降,模型同理。

1.2 Prompt 只是上下文的一小部分

把一次典型 Agent 调用的上下文拆开看,你会发现 Prompt 占比其实很小:

1
2
3
4
5
6
[System]  你是订单客服助手,规则如下……        ← prompt 工程,占比可能不到 10%
[History] 过去 40 轮对话记录…… ← 历史消息,越滚越大
[Tool] search_order() 返回的一大坨 JSON ← 工具结果,往往最占空间
[RAG] 检索回来的 8 段知识库文档…… ← 检索内容
[Memory] 用户偏好:喜欢简洁回复、深圳用户…… ← 长期记忆
[User] 帮我查一下上周的订单 ← 用户真正的输入

真正决定模型发挥水平的,是这五块内容的组织方式。工程师能”工程”的,也正是这五块。这就是我理解的上下文工程:它不是写文案,而是一种信息架构工作。对前端同学来说,这个味道是不是很熟悉?后面第 5 节我会展开这个类比。

2. 上下文的五大构成

2.1 System Prompt:宪法而不是说明书

System Prompt 定义角色、边界和输出格式。我的经验是:规则越少越好,但每条都必须是”硬约束”。把几百条注意事项全塞进去,模型一条也记不住,这本身就是一种上下文污染。

2.2 工具结果:最容易失控的部分

Agent 一跑起来,工具返回值是最膨胀的内容。一次 search_order 返回 50KB 的 JSON,三轮下来上下文就被工具结果淹没了。工具结果必须裁剪,第 3.2 节我会给具体做法。

2.3 检索内容:RAG 的质量决定上限

RAG 检索回来的片段质量参差,8 段里混着 5 段不相关的,模型反而会被带偏。检索内容进上下文之前,最好再做一次相关性过滤或重排。

2.4 记忆:跨会话的长期信息

用户偏好、历史结论、已确认的事实。记忆的难点不在写入而在”过期”:用户半年前说喜欢邮件通知,上周改成了短信,旧记忆不清理就是定时炸弹。

2.5 历史消息:多轮对话的滚雪球

多轮 Agent 的历史消息是线性增长的,而大量轮次里的信息其实已经过时(”刚才那个报错已修复”)。历史必须压缩,这是下一个重点。

3. 实战:给一个多轮 Agent 瘦身

我们的客服 Agent 原始版本很朴素:历史消息全量保留、工具结果原样进上下文。跑 20 轮之后上下文逼近 80K token,响应变慢、费用飙升,模型还开始”忘了”最开始的用户诉求。下面是三个逐步优化手段。

3.1 历史消息:滑动窗口 + 摘要压缩

思路:保留最近 N 条原文(保证近期细节),更早的部分让模型压缩成一条摘要,替换掉原始消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// history-compactor.ts
type Message = { role: 'user' | 'assistant' | 'system' | 'summary'; content: string };

export class HistoryCompactor {
constructor(
private complete: (messages: Message[]) => Promise<string>, // 底层模型调用
private threshold = 20, // 超过 20 条触发压缩
private keepRecent = 6, // 保留最近 6 条原文
) {}

async compact(history: Message[]): Promise<Message[]> {
if (history.length <= this.threshold) return history;

const toSummarize = history.slice(0, history.length - this.keepRecent);
const kept = history.slice(-this.keepRecent);

const summary = await this.complete([
{
role: 'system',
content:
'把以下对话压缩成要点摘要,必须保留:用户的核心诉求、已确认的事实、' +
'已经做过哪些操作、未完成的事项。300 字以内,不要评论。',
},
{
role: 'user',
content: toSummarize.map((m) => `${m.role}: ${m.content}`).join('\n'),
},
]);

return [
{ role: 'summary', content: `[历史摘要,细节已省略] ${summary}` },
...kept,
];
}
}

三个实战注意点:

  1. 压缩放后台异步做,别阻塞当前轮次。当前轮先用旧历史顶上,压完再替换。
  2. 摘要要标成独立的 summary 角色,并在内容里注明”细节已省略”,否则模型会以为摘要就是全部事实,丢失时反而硬编。
  3. 压缩本身也是一次模型调用,有成本。我们的做法是每新增两条消息才触发一次检查,且摘要调用用便宜的小模型。

3.2 工具结果裁剪:截断 + 投影

第一层是通用截断,保留头尾(很多日志关键信息在开头和结尾):

1
2
3
4
5
6
7
// trim.ts —— 通用工具结果截断
export function trimToolResult(raw: string, limit = 2000): string {
if (raw.length <= limit) return raw;
const head = raw.slice(0, Math.floor(limit * 0.7));
const tail = raw.slice(-Math.floor(limit * 0.2));
return `${head}\n...[已省略 ${raw.length - head.length - tail.length} 字符,如需完整数据请分页查询]...\n${tail}`;
}

第二层更有效:投影(projection)。与其让模型读 50KB 的订单 JSON,不如只保留模型任务需要的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 投影:把 50KB 的订单对象收敛成模型真正需要的几百字节
function projectOrder(order: FullOrder) {
return {
id: order.id,
status: order.status,
created_at: order.createdAt,
amount: order.amount,
logistics: {
company: order.logistics?.company,
latest_event: order.logistics?.events?.at(-1)?.description,
},
// address、发票、优惠券等几十个字段全部丢弃
};
}

白名单式投影还有个隐性好处:敏感字段(手机号、地址详情)根本不进上下文,天然降低了数据泄漏面。

3.3 结构化上下文模板

零散拼接的上下文,模型读起来像一锅粥。我们的最终形态是用固定模板把上下文分区(XML 标签分区是目前效果最稳的方式之一):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<context>
<user_profile>
深圳用户,偏好简洁回复,会员等级 VIP3。
</user_profile>
<working_memory>
[历史摘要] 用户反馈订单 A123 未收到货,已核实物流停滞,正在协调补发。
</working_memory>
<retrieved_docs relevance="high">
补发流程:需先创建售后单,再调用 redispatch 工具……
</retrieved_docs>
<tool_results>
{"order_id":"A123","status":"物流停滞","latest_event":"包裹在转运中心滞留超过48小时"}
</tool_results>
</context>

分区的好处:模型对”这一块是什么性质的信息”判断更准,引用时不容易张冠李戴;同时我们可以在代码层面按区做预算控制:比如 retrieved_docs 最多占 4K token,超了就取 top 段落。

4. 踩坑:上下文污染与注意力稀释

4.1 无关信息真的会让模型变笨

一个反直觉的事实:上下文里的无关信息不只是浪费 token,而是实打实地拉低回答质量。学术界叫 Lost in the Middle:模型对上下文开头和结尾的内容注意力最强,中间部分最容易被忽略。

我们踩过的实例:知识库检索召回 8 段文档,其中 5 段是另一个产品线的旧文档。模型回答时把两个产品的退款政策混在一起,一本正经地胡说八道。修复方案是在检索层加相关性阈值 + 重排,宁可只给 2 段高相关的。

4.2 过期信息比没有信息更糟

更阴险的坑是”过期信息”。我们的 Agent 曾把三个月前的价格表(来自一次缓存的工具结果)一直留在多轮上下文里,之后用户无论怎么问,模型都坚持报旧价格——因为上下文里白纸黑字写着,模型对上下文的信任度高于它的参数知识。

防御原则:

  • 宁缺毋滥:不确定相关性的内容不进上下文。
  • 及时作废:失败的、被推翻的工具结果要显式标记”已失败/已过时,勿参考”,最好直接移除。
  • 重要信息放头尾:关键约束写进 System Prompt(开头)或最终指令(结尾),别埋在中间。

5. 给前端的启示:上下文即新的”状态管理”

像设计状态一样设计上下文:分区、预算与优先级

写到这里,做了这么多年前端的我越来越觉得:上下文工程本质上是新一轮状态管理。对照着看几乎是一一映射:

React / 状态管理世界 上下文世界
props / 一次性消息 user message
全局 store System Prompt + 长期记忆
状态归一化、派生数据 工具结果投影、裁剪
缓存失效 过期信息清理
组件渲染优先级 token 预算与优先级装填
重渲染范围控制 上下文分区与隔离

于是我们照着 store 的思路写了一个上下文管理器:每个信息块带优先级和 token 估算,按预算装填,超预算的低优先级块直接丢弃,和 React 里”有限资源下的渲染调度”是同一个思想:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// context-manager.ts —— 把上下文当成一个 store 来管理
interface ContextBlock {
id: string;
tag: string; // xml 分区标签,如 user_profile
content: string;
priority: number; // 越大越重要
order: number; // 声明顺序,决定最终拼接位置
}

export function estimateTokens(text: string): number {
// 粗估:中文 1 字 ≈ 1.5 token,英文 4 字符 ≈ 1 token
const cjk = (text.match(/[\u4e00-\u9fa5]/g) ?? []).length;
return Math.ceil(cjk * 1.5 + (text.length - cjk) / 4);
}

export class ContextManager {
private blocks = new Map<string, ContextBlock>();

set(block: ContextBlock): void {
this.blocks.set(block.id, block);
}
invalidate(id: string): void {
this.blocks.delete(id); // 相当于缓存失效,过期信息立即清理
}

build(budget = 30000): string {
const sorted = [...this.blocks.values()].sort(
(a, b) => b.priority - a.priority,
);
let used = 0;
const picked: ContextBlock[] = [];
for (const b of sorted) {
const tokens = estimateTokens(b.content);
if (used + tokens > budget) continue; // 预算不够,低优先级块落选
picked.push(b);
used += tokens;
}
// 按声明顺序拼回,保证语义连贯
return picked
.sort((a, b) => a.order - b.order)
.map((b) => `<${b.tag}>\n${b.content}\n</${b.tag}>`)
.join('\n');
}
}

这个设计有三个要点:token 预算(任何信息进上下文都要”付租金”)、优先级装填(资源紧张时先丢什么)、声明顺序输出(装填按优先级、输出按语义顺序,两回事)。把这三件事想清楚,你的 Agent 上下文就从”一锅粥”变成了”一个有纪律的 store”。

写在最后

从 Prompt 工程到上下文工程,措辞变了,工程重心也跟着迁移:模型能力越强,”喂给它什么”就越比”怎么问它”重要。这套活儿对前端不陌生:我们花了十年学会管理浏览器里的状态,现在只是把同一套功力用进模型窗口。

下一步我打算把 ContextManager 和 HistoryCompactor 抽成独立的开源小库,跑通真实项目后再写一篇续集。眼下还有一个没拿准的问题:历史摘要压得太狠,用户早期随口提过的偏好会不会跟着丢?小库开源前,我得先拿几条真实会话验证一遍。