有朋友问我,手搓 Agent 时最容易在设计阶段想当然、在线上阶段翻车的是什么。我多半会回答:上下文工程。倒不是因为它难,而是因为我们太习惯拿人的记忆去类比模型了。你总觉得,多轮对话里出现过文件名,模型就一定“记得”;你觉得把规则写在 system prompt 里,它就“一直在心里”;你觉得上下文窗口足够大,信息塞进去就能被利用——但实际上,LLM 根本不是这样工作的。
这篇是手搓 Agent 系列的第三篇,先不急着讲摘要、压缩、检索排序这些具体手段,而是把最基础的问题彻底说透:LLM 到底看到了什么?它看到的是一份什么样的“材料”?为什么同样一段文字,放在不同位置、用不同方式表达,结果会差很多?把这个问题理解准了,后面的上下文工程手段才有落点,否则你只是在瞎调 prompt。
1. 你以为它在“读”,其实它在“算”:一次请求的前世今生
1.1 从原始文本到 token:第一道“翻译”就丢信息
先看一次请求走的最原始路径。你发给模型的当然不是“对话”,而是一串字符串。模型 API 接收到这串字符串后,内部第一件事就是分词(tokenization):把连续文本切分成 token 序列。
关键点在于:token 既不是“字”,也不是“词”,它是子词片段。一个常见英文单词可能是一个 token,但一个专有名词可能被切成三四个片段;中文里的一个汉字,在不同分词器下可能是一个 token,也可能和周围字符拼成一个更长的片段。你千万别拿“字数”去估算 token 数,这在上下文工程里是第一个坑。
我见过一个典型场景:某开发者在 Agent 里给用户展示“已经消耗的 token”,直接数 prompt 里的汉字数,以为 1000 字就是 1000 token,结果实际计费比预期高出一大截。因为代码片段、URL、JSON 里的键名、UUID 这类内容,token 消耗可能比同样长度的自然语言高出两三倍。
所以我有一条很朴素的经验:凡是涉及上下文长度判断,永远先过一遍 tokenizer。大多数主流模型都有对应的开源分词器或在线统计工具,本地跑一个小脚本就行,别用眼睛估。
1.2 嵌入向量与位置编码:LLM 眼中的“字符”是一串数字
分词之后,每个 token 会被映射成一个高维向量,这一步叫嵌入(embedding)。这个向量本质上是一个“语义坐标”,含义相近的词在向量空间里距离更近。比如“苹果”和“橘子”的向量距离,远小于“苹果”和“汽车”的距离。
但这还不够,因为模型需要知道顺序。所以每个 token 的向量还会叠加位置信息。现在主流模型大多采用相对位置编码那一族设计,核心思想是让模型知道“两个 token 相隔多远”,而不是单纯记住绝对坐标。这是它理解一句话结构的关键。
接着,这串带位置信息的向量会依次经过多层 Transformer 模块。每层都在做同一件事:让每个 token 根据它与上下文里其他 token 的相关程度,去“收集”信息、更新自己的表示。所有层的输出最终会被换算成“下一个 token 的概率分布”,模型每生成一个 token,就从这个分布里采样一次,然后把新 token 接在末尾,再继续走一遍前向计算,直到生成结束。
你可以把这个过程想象成一个考场阅卷官:你递给它一沓标好顺序的卡片,它只能在这沓卡片里找线索,不能翻外面的资料,也不能回忆“上次考试”考了什么。它每写一句答案,都会重新扫一遍这沓卡片,然后写下最可能的那张卡片序号。
这就是 LLM“看到”世界的全部方式。它没有一个持续存在的“内心”,只有一次请求、一次前向计算、一次输出。所有你以为的“对话历史”“角色设定”“工具返回结果”,在它眼中只是同一沓卡片里的若干张,地位没有本质区别。
2. 注意力不是眼睛:为什么“看到全部”不等于“注意到全部”
2.1 自注意力到底在看什么
Transformer 架构最核心的是自注意力机制。它的工作方式可以用一个文件检索场景来类比:假设你手头有一个问题,需要在一屋子文件里找答案。每个文件都贴了一张“检索标签”(Key,简称 K),还附上了一段“候选答案”(Value,简称 V)。你的问题本身是一个“查询向量”(Query,简称 Q)。
计算过程是:用 Q 和每个文件的 K 做相似度打分,再把得分经过归一化变成权重,最后按权重把所有候选答案 V 加权求和,得到最终结果。注意力权重越高,说明当前 token 越“关注”文件里对应的位置。
这套机制的美妙之处在于,它不需要提前知道“哪一段重要”,而是让模型在训练中自动学会“遇到什么类型的 Q,应该去哪些 K 上找信息”。但它的副作用也很明显:上下文里的每一项都会影响最终输出,哪怕是一个毫无关联的历史片段,也会占据一部分注意力预算。
更关键的是,模型每一次生成新 token,都要重新对整个上下文做一遍注意力计算。也就是说,你请求里携带的每一段历史、每一段系统提示、每一段工具返回内容,不只是“放着”,它们的每一个 token 都会被反复“阅读”并影响后续生成方向。
这里有个很容易误解的地方:API 里虽然有 system、user、assistant 角色标记,但对模型本身来说,这些角色标记并不特殊。它们只是被编码成 token,然后模型通过注意力学习到了一种“指令感”——看到 system 标记后的内容时,倾向于把它当作更高优先级的约束。但这种“倾向”并不是硬编码的,它依然受位置、长度、内容冲突等因素干扰。
2.2 lost in the middle:上下文中间地带的信息衰减
很多研究者在做长文本实验时发现过一个现象:当答案位于上下文正中间时,模型的回答准确率会显著下降;位于开头或者结尾时则好得多。这现象在圈子里叫“lost in the middle”。
为什么会这样?仔细想想并不奇怪。模型在训练和生成时,开头的内容(通常是系统提示)会被当成“当前任务设定”去重点关注;结尾的内容(通常是最近一轮用户消息)天然和最新目标最相关,注意力也容易集中在那里。而夹在中间的历史对话、中间推理过程,往往只被分配了较低的注意力权重。
这对 Agent 开发的影响是实打实的。我见过一个搜索型 Agent,用户在对话中段说“把上一份报告里的第三点展开”,但 Agent 最终给出的答案把第三点和第二点混在一起,看起来就是模型“读到了但没读到”。不是它偷懒,而是那第二点、第三点正好埋在上下文的中间地带,注意力没有充分覆盖。
所以如果你的 Agent 里有“关键指令”,把它放在上下文的开头和结尾各强调一次,是成本最低的提效手段。不要只放在系统提示里,也不要只放在最新用户消息里,双保险比单保险稳得多。
2.3 KV Cache:窗口大小的隐形资源上限
理解注意力之后,就能明白为什么上下文窗口本质上是一个资源限制,而不是一个“存储空间”。在生成第 N 个 token 时,模型需要重新计算第 N 个 token 的 Query,但前面 N-1 个 token 的 Key 和 Value 理论上是可以复用的。推理框架正是借助这一点,把历史的 K 和 V 缓存下来,这就是 KV Cache。
问题在于,这些缓存是要占显存的。上下文越长,KV Cache 越大,占用的显存越多。更麻烦的是,输入和输出会共同抢占同一个窗口预算:模型能生成的最大 token 数,不是 max_tokens 这个参数本身,而是 min(max_tokens, 上下文窗口 - 当前输入长度)。
我举个具体例子:窗口 8000,当前输入已经占了 6500,max_tokens 你设置了 4096。这时候模型实际最多还能生成 min(4096, 8000 - 6500) = 1500 个 token。你满心期待它能输出一篇长文,结果它写到 1500 左右突然停了,看起来像“犯懒”,其实就是窗口余量不够了。
这个数字关系在实际开发中非常容易踩。排查“模型回答被打断”类问题时,我建议先算这一步:当前输入到底占了窗口多少?还有多少余量给输出?很多莫名其妙的半截输出,根因都在这里,不在模型本身。
3. 上下文不是记忆仓库,只是一串瞬时并行的数字
3.1 “记住”是假象:LLM 没有状态
这是上下文工程最根本的认知:LLM 是无状态的。每一次请求,模型看到的都是“此刻提交进来的所有内容”,它对上一次请求没有任何记忆——哪怕这两次请求在业务上属于同一个 session。
那为什么我们平时用对话应用时,模型“看起来”记得之前聊了什么?因为上层框架帮你做了拼接:把历史消息作为 message 数组一起发给模型。模型不是想起了上次的聊天,而是这次请求里就躺着上次聊天的文字记录,它只是基于这些文字重新推断罢了。
这个区别非常重要。你写 Agent 时,如果依赖某种“记忆”直觉,觉得用户上一轮说过的文件名、偏好、要求会自动生效,那就危险了。每次调用模型,都是一次全新的前向计算,历史记录不会自动出现,必须由你手动带进去;带进去也不是“回忆”,而是作为新的输入文本被重新编码一次。
我一直用一个比喻:LLM 就像一个考场里只做一张卷子的考生。你每提交一次请求,就是在给他换一份新卷子。上一份卷子的内容只存在于上一份卷子里,考生不会因为“上次做过”就更懂这次,除非你把上次的内容抄进这次的卷子。你抄得越完整、条理越清楚,他答得越好;你抄得残缺、混乱,他答得就稀烂。
| 常见直觉 | 真实机制 |
|---|---|
| 多轮对话久了,模型应该“记得” | 每次请求都是全新前向计算,历史只是被重新搬进输入 |
| 系统提示写一次就永久生效 | 系统提示每次都要重新参与计算,且位置靠前有注意力衰减风险 |
| 上下文窗口越大,能装下并利用的信息越多 | 窗口大只是上限高,注意力对中间段内容的有效利用有限 |
| 对话历史是“背景”,不影响当前回答 | 历史里的每个 token 都会参与注意力计算,随时可能干扰当前输出 |
3.2 窗口撑爆之后:静默截断才是最危险的坑
多轮 Agent 跑起来之后,最直观的问题就是 token 数量不断累积。你每一轮把聊天记录、工具结果、中间思考过程全部拼进请求,上下文就像滚雪球一样越滚越大。等到总长度超过窗口限制,不同服务有不同的处理方式。
有的会直接报错,告诉你 context length exceeded,这个还好,至少你知道出问题了。最怕的是静默截断:服务自动把多余部分丢掉了,但 API 调用仍然正常返回结果。而丢弃策略通常是丢最前面的内容,这意味着系统提示可能被扔掉,角色设定、工具规则、整体任务说明全部消失。模型还在接着答,但已经不知道“自己是助手”“不许调用工具”“回答要遵循什么格式”了,行为开始漂移。
这类问题在日志里非常隐蔽,因为请求本身没报错,返回的文本看起来也通顺,只是偶尔回答得“味道不对”。我排查这类问题最常用的手段是:在发送前把真实请求完整记录下来,检查实际发送的 message 数组里 system 内容是否健在、历史是否被截断、工具结果是完整还是残缺。等日志统计出来,问题通常一目了然。
如果确实需要手动截断,我的优先级顺序是这样:先保系统提示,再保当前一轮用户请求,然后是当前正在执行的任务上下文,最后才是历史对话。历史对话如果太长,优先做摘要,而不是无脑截断,因为无脑截断可能把关键信息拦腰切断。
4. Agent 场景的上下文污染源:三个反复出现的隐藏杀手
4.1 过期工具结果:模型会一本正经引用旧数据
Agent 和普通聊天最大的区别,在于上下文里混入了工具调用结果。工具结果有个天然问题:它有时效性。你可能先调用一个查询接口拿到昨天的数据,又调用另一个接口拿到今天的库存,这两个结果同时躺在上下文里。
模型并不知道哪个是旧的、哪个是新的。它没有时间轴概念,不会自动给“旧数据”打折。它只是按照注意力机制,把所有信息当作当前可用的真实情况来处理。于是你可能会看到 Agent 煞有介事地引用已经失效的数据,而且语气非常自信。
我踩过最典型的坑是一个库存查询 Agent:用户先问“A 商品还有货吗”,Agent 查到有货;隔了几分钟又问“A 商品可以下单吗”,这时库存其实已经清了,但 Agent 看到上下文里还有“有货”的记录,直接回答可以下单,结果用户下单失败。
这类问题靠 prompt 很难根治。最实用的做法是在工具返回结果时给数据打上显式的时间标签,比如“截至 14:30 的数据”;如果同一类数据被多次查询,旧结果要主动标记为已过期;甚至可以只保留最新一次的工具结果,把旧的从上下文里清除或压缩。别让模型去推断时效性,它没有这个能力。
4.2 被省略的隐含指令:模型没有“你之前说过的”这种回忆
另一个高频污染源,是我称之为“指代悬空”的问题。用户在前一轮对话里说“把那份合同改一下”,后一轮说“乙方赔付比例改成 20%”,两者单独发给模型时,第二个请求如果只带着“乙方赔付比例改成 20%”,模型根本不知道改的是哪份合同。
如果你做过多轮 Agent,肯定见过这种回答:模型非常诚恳地把 20% 这个数字输出出来了,但改在了完全错误的文件或段落上。人看了会觉得模型很笨,但站在模型角度,它确实只是在当前输入里尽力推断——它没有“上一轮我改过哪份合同”的回忆能力。
这个问题的解决思路只有一个:所有必要信息要在当前请求里重新补齐。要么保留完整的对话历史,要么在发请求前做一个指代消解——把用户当前消息中的“那个文件”“这家公司”“这个需求”等指代对象,替换成具体的文件名、公司名和需求描述。我实际操作时倾向于后者,因为完整保留历史会带来上下文膨胀和注意力稀释,代价更大。
工程上有个原则我一直在用:发给模型的每条请求,都要假设它什么都看不到。所有必要条件必须写进当前输入,不能依赖隐式状态。
4.3 指令冲突与角色漂移:上下文越长,权重越模糊
第三个隐藏杀手是“指令打架”。当上下文很长时,里面可能同时存在相互矛盾的指令:系统提示说“输出纯文本”,某轮历史里用户又说“用 markdown 列表”;系统提示说“不要提及工具名称”,某个工具结果里却印着大大的工具名。模型面对这些冲突,不会像人一样“分辨新旧”,它只会按注意力权重进行混合——结果就是有时这次听 A,那次听 B,表现为随机摇摆。
问题更隐蔽的是角色漂移。你原本的系统提示是简洁的助手设定,但随着工具结果、示例对话、检索片段不断加入,上下文里混入了一些“像指令”的文本——比如某个网页摘要里写着“你应该首先分析用户意图”,模型可能把这当成了新的行为规范,逐渐偏离原始角色。
这类问题的修复方式,不是单纯把系统提示加长、加强语气,而是要从源头减少冲突。我一般会在发送前做一次上下文清洗:把旧的、已执行的指令改写或剔除,对工具结果做格式标准化,把可能有歧义的大段检索文本压缩成结论性摘要。让上下文保持“干净”,比让 prompt 更有“说服力”重要得多。
5. 原理转工程:三条能立刻落地的上下文审计方法
5.1 审计真实请求体,而不是审计“你以为的”
我知道很多开发者联调 Agent 时,会在代码里打印 messages 变量,看到列表挺完整就觉得万无一失。但真实发送给模型的 request body,往往经过了各种封装:可能有系统提示注入器、可能有截断函数、可能有历史压缩器、可能有格式转换层。你在业务代码里看到的 messages,和真正发出去的内容很可能不是同一份。
我有一次排查 Agent 行为漂移,花了两个小时看业务逻辑,最后发现罪魁祸首是一个“不起眼”的中间层,它为了省成本,把超过窗口阈值的历史内容直接丢掉,而丢的正好是 system 段。业务代码打印的 messages 是完好的,但真正到模型手里已经变了。
所以我的建议很简单:在发送前把最终的 request body 完整 dump 到日志,保存原始 JSON。这个日志文件会比较大,但它太值了。上下文出问题一次,排查成本远超你省下的那点日志存储费。每次调上下文逻辑,先审真实发送内容,再谈优化。
5.2 用盲测定位模型的关注偏好
不同模型对上下文不同位置的敏感度不完全一样,虽然“头尾强、中间弱”是普遍规律,但具体到你的 Agent 场景,最好自己做一次盲测。
做法是:构造同一个任务,准备同一段关键信息,分别把它放在上下文的开头、中间、结尾,各跑 5 次,记录回答质量和稳定性。为了减少随机性干扰,测试时温度设为最低,保证输出尽量可复现。然后对比哪一组位置的回答最稳定、最准确。
这个测试能直接指导你的截断策略:如果模型对开头信息极其敏感,那截断时必须保住系统提示;如果模型对结尾信息敏感,那最近一轮用户消息和最新的工具结果优先级就得更高。做一次测试,比你说一百句“我觉得模型更关注哪里”更有说服力。
我还建议你在盲测时故意加入一段“干扰信息”,比如一段毫不相关的工具返回内容,看看模型会不会被带偏。如果测试发现模型非常容易被干扰,那你的上下文清洗就要更激进,最好是每轮只保留和当前意图直接相关的工具结果。
5.3 给上下文做“token 营养标签”
上下文工程做久了,你会发现大多数 Agent 项目根本不知道自己的输入里各部分占了多少 token。系统提示多少?工具描述多少?历史对话多少?检索片段多少?没人说得清,大家只会在出问题后瞎猜。
我给项目做体检时,习惯给上下文打一张“token 营养标签”,把所有组成部分的 token 占比列出来,就像食品配料表一样:
# 估算上下文各部分的 token 占比(请替换为你所用模型对应的 tokenizer) from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("你的模型标识") parts = [ ("system_prompt", 你的系统提示), ("tool_schemas", 工具定义描述), ("history", 历史对话), ("tool_results", 工具返回结果), ("current_user", 当前用户消息), ] total = 0 stat = [] for label, content in parts: tokens = tokenizer.encode(content, add_special_tokens=False) stat.append((label, len(tokens))) total += len(tokens) for label, count in stat: print(f"{label}: {count} tokens, {count/total:.1%}") print(f"total: {total} tokens")别小看这张表,它能直接暴露很多问题。比如历史对话占了 80%,说明你是在盲目搬运历史,而不是在有效管理上下文;工具定义占了一半,说明工具描述写得过于啰嗦,字段说明可以压缩;系统提示只占 5%,那你得想想这段 5% 的文本能不能压住后面 95% 的干扰。有了这个数据,你做的每一步优化都是可量化的,不是凭感觉。
如果你用的模型没有直接可用的本地分词器,也可以用厂商提供的 tokenizer 接口做近似估算。估算精度不需要像素级,能用它是为了做前后对比、定位占比失衡。趋势比绝对值更重要。
把上面这些实践过一遍之后,我自己最大的变化是:不再把上下文当成“对话记录”去管理,而是当成“一份要交给陌生人的考试材料”去设计。每次发请求前我都会问一句——如果这个请求落到一个从来没有和我聊过的助手手里,它能不能只凭这份材料拿满分?如果答案是不能,那问题几乎一定出在上下文工程上,而不是模型能力上。
下一篇准备接着讲上下文工程的下半部分:历史摘要怎么做、语义压缩怎么选、工具结果怎么保存和清洗,以及 Agent 长跑时的上下文预算管理。但无论后面的手段多花哨,都绕不开一个问题——你得先准确知道 LLM 到底看到了什么。这一篇讲清楚了,后面的方法论才有根。