跳到主内容
创建时间:最后更新:作者:白川

Token 与上下文:为什么长对话会丢失信息

用可测量的数字理解 token 计量、上下文容量与五种"找不到",建立长任务的信息预算直觉。

Token 与上下文:为什么长对话会丢失信息

你已经在前面贴过文件——比如那个教学用的 src/greet.ts——模型后来却说找不到。原因可能是它理解有误,也可能是那段内容没有进入这次请求。聊天界面保留历史,与模型当前能够使用哪些信息,是两件事。

"找不到"这件事至少有五种不同的原因:请求本身太大、输出被截断、历史被裁剪过、文件根本没读进来,以及信息读进来了但模型在长上下文中间没找到它。这五种原因要用不同的方法排查,混在一起就会一直换错方向。

顺带说明,src/greet.ts 是贯穿本组文章的教学文件,本文用它做容量推演,不代表已经对你的项目执行过检查。

这一篇先把"多少信息能进去"这件事变成可测量的数字,再说清楚每一类失败各自留下的痕迹。

Token 是计量单位,不是固定字数

模型通常把文本转换为 token 序列。一个汉字、一个英文单词、一段代码分别占多少 token,取决于分词方式和具体内容,不能直接用字符数或文件字节数替代。

OpenAI 的官方说法是经验值:1 token 大约对应 4 个字符,或大约四分之三个单词。这个换算常被当作通用标准,但它只对英文常见文本成立。tiktoken 的 README 给出的机制更具体:字节对编码(Byte pair encoding)"平均而言,每个 token 大约对应 4 个字节",并且会优先把常见子词切出来——"encoding" 会被拆成 "encod" 和 "ing" 两个 token,而不是 "enc" 和 "oding"。

换成中文,同样的字符数会切出更多 token。所以"这段代码大概多少 token"这种估算,在英文项目里能用,在中文提示词上误差会明显放大。

图片和文档是另一类计量。Anthropic 那组样本里,一张图片让输入从 14 涨到 1028,一份 PDF 涨到 2188——它们不按字符数折算,而是作为独立的输入块计入。计数接口要求图片和 PDF 都以 base64 提交,返回的是整个请求的 input_tokens 总数。Pi 在本地估算时把每张图片折算成固定字符数再除以 3.5,得到的只是量级估计,真实开销以 Provider 上报为准。

所以"先贴文件再说"这个习惯在长任务里代价很高:一次截图或一份 PDF,可能比整段对话加起来还贵。

这还有一个连带后果:图片和文档一旦进入对话历史,就会长期占用上下文。Pi 的模型配置里因此有专门的图片处理项(inputLimits.images.resize),用来控制新图片在进入历史前如何编码与缩放。但文档也写明,已经进入历史的图片不会因为后来换了模型而被重新编码——早期的附件开销会一直留在那里。

还有一件事会让历史数字直接失效:换模型可能等于换分词器。Anthropic 在 token 计数文档里写得很明确——"Claude 4.7 and later models and Claude Mythos Preview use a newer tokenizer. The same input text produces approximately 30 percent more tokens than on earlier models."同一段文字,换到新分词器上会多出约三成 token。文档同时提醒:计费和用量都按新分词器计算,迁移时不要拿旧模型上测出来的数字去估算新模型的成本或窗口占用。

Pi 的估算方式又不一样。它在 packages/ai/src/utils/estimate.ts 里用每字符 3.5 个 token 的经验值做本地估算,但优先采用上一次响应里 Provider 真实上报的用量,再对之后新增的消息做估算。所以 Pi 界面上的上下文占用是"上报值加估算值"的混合结果,接近但不完全等于 Provider 侧的精确计数。

一个工具定义要花掉多少 token

这是最容易低估的一项。Anthropic 官方文档用 count_tokens 端点给了四组可以直接对照的实测样本:

请求内容input_tokens
一条基础消息
14
同一条消息 + 一个工具定义
403
同一条消息 + 一张图片
1028
同一条消息 + 一份 PDF
2188

从 14 到 403,只因为多加了一个工具定义——工具的名字、描述和参数 schema 都会被写进请求正文。一条消息和一份文档的差距更直观:14 和 2188 相差两个数量级。

这解释了为什么"任务很简单"不等于"请求很小"。宿主每注册一个工具、每挂一条上下文文件,系统提示和工具声明就会一起变长;而这部分开销每轮都要重发,因为 Provider 的缓存通常只覆盖稳定前缀,不覆盖每轮变化的尾部。

想在自己的项目里测同样的数字,可以直接向 Provider 的计数端点发一次同样的请求体,拿到 input_tokens 再和实际请求对照。这个方法比估算可靠,因为它用的就是目标模型的分词器:

bash
curl https://api.anthropic.com/v1/messages/count_tokens \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "content-type: application/json" \
  -H "anthropic-version: 2026-10-21" \
  -d '{
      "model": "claude-opus-5-5",
      "system": "You are a scientist",
      "messages": [{
        "role": "user",
        "content": "Hello, Claude"
      }]
    }'

把这里的 messages 换成你真正要发的内容,就能拿到这次请求的精确输入 token 数。测出来的数字和实际请求之间的差值,就是系统提示、工具声明和其他固定开销。

Pi 怎么给容量建模

在 Pi 里,模型的能力来自 Model 类型上的两个数字:

字段含义来源
contextWindow
总上下文窗口
模型目录中的固定值
maxTokens
单次响应的输出上限
模型目录中的固定值

读源码时留意一件事:这两个字段的含义和名字并不完全对称。contextWindow 是输入和输出共用的一块容量,maxTokens 只是输出侧的名义上限,不是一块独立配额。真正生效的输出上限由下面那段计算决定。

注意这里没有 maxInputTokens 和 maxOutputTokens 这样的字段。业界确实存在分别规定输入、输出与总容量的情况,Pi 也确实在模型配置里同时暴露了 contextWindow 和 maxTokens,但两者不是各自独立的配额:可用的输出空间是算出来的,不是写死的。

计算发生在 packages/ai/src/api/simple-options.ts 的 clampMaxTokensToContext 里:

ts
const input = estimateContextTokens(context).tokens;
const margin = Math.max(CONTEXT_SAFETY_TOKENS, Math.ceil(input * CONTEXT_SAFETY_RATIO));
let room = model.contextWindow - input - margin;

输入占用越多,留给输出的空间越小;还要额外扣掉一层安全余量,因为 Pi 的本地估算可能比 Provider 的实际计数偏低。这层余量不是固定值:一部分来自一个常量,另一部分按输入量的比例放大,用来覆盖估算误差。窗口快满时,输出上限会被压到只剩固定余量。

举个具体例子。设窗口是 200000,当前输入估算 150000:

项目数值说明
contextWindow
200000
窗口总容量
input
150000
本次请求已占用的输入估算
margin
约 22500
常量与输入 15% 中的较大者
room
约 27500
留给本次输出的空间
最终 maxTokens
取 min(maxTokens, room)
还受模型自身输出上限约束

可以看到 maxTokens 是模型能力的名义上限,而真正生效的值由 room 决定。输入越大,输出被压缩得越狠——一个占掉四分之三窗口的请求,模型最多只能写两万多 token。

输入这一侧的限制方式也和直觉不同。Pi 不会用 token 数去卡输入体积,而是用字节数:inputLimits.maxRequestBytes 表示"序列化后的 Provider 请求体最大字节数"。文档同时说明,Pi 目前不会因为超过这些限制而重写或拒绝历史记录。所以"输入超限"这类失败,通常先由 Provider 报错,而不是被 Pi 提前挡住。

这条分工值得记一下:Pi 负责估算和预留,Provider 负责判定。Pi 用本地估算决定这次给模型留多少输出空间,然后用请求去试探 Provider 的硬限制。两边的口径不同(一个按字符估算,一个按字节和精确 token),中间那层余量就是为这个差异准备的。

一次请求里都装了什么

把上面这些数字和 Pi 的实际组装方式放在一起看,一次请求大致由几部分构成:

组成部分在 Pi 里对应什么为什么占空间
基础指令与规则
系统提示的 preamble、rules 段
说明要做什么、不能做什么
工具声明
系统提示的 tools 段,加上每轮的工具描述
让模型知道有哪些可用动作
项目上下文
project_context 段,来自发现到的上下文文件
提供任务相关的约定
可用 Skill 清单
skills 段
路由依据
对话历史
当前分支上的消息
解释已经发生的动作
工具结果
工具执行返回的内容
事实证据,往往最占地方
生成空间
clampMaxTokensToContext 算出的余量
留给本轮响应

这张表可以当作"有限工作台"的类比:每样东西都在抢同一块桌面。但类比到此为止——真正决定这次请求有多大的,是上表每一行的具体字节数,而不是格子数量。前面那个 14 与 403 的对比已经说明,工具声明这一格可以单独吃掉二十多倍的空间。

一个补充细节让这个竞争关系更明显:系统提示里的内容不是每轮重算一遍就算完的。Pi 把系统提示拆成若干具名段落(preamble、tools、rules、docs、project_context、skills、cwd),只在内容变化时才替换对应的那一段,其余保持不变。这样一来,稳定的前缀有机会命中 Provider 的提示缓存,而变化的尾部照常重发。代价是——缓存命中的前提是前缀稳定,一旦工具集或项目上下文发生变动,这层复用就失效了。

五种现象不要混为一谈

现象实际发生的事可以检查的方向
请求超限
构建的输入不被接口接受
容量、工具说明、附件和历史体积
输出截断
响应到达生成上限等边界
结束原因和输出设置
历史裁剪或压缩
宿主未发送完整原始历史
当前有效请求和摘要来源
信息未读入
相关文件根本没有进入请求
文件读取、检索与工具结果回填
读到了但没找到
信息在上下文里,但模型没能在长上下文中定位到它
信息在请求中的位置、检索与重排方式

前四种会留下可见痕迹:报错、缺失的工具结果、被替换掉的历史。第五种最麻烦——它不会留下任何错误。请求正常发出,模型正常回复,只是它漏掉了本来就在眼前的那条信息。

第五种不是 Pi 特有的问题。Lost in the Middle 一文(TACL 2024)在摘要中给出了一致的观察:"performance is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access relevant information in the middle of long contexts, even for explicitly long-context models."即使模型本身宣称支持长上下文也是如此。

这解释了为什么"窗口够大就行"不成立。宿主能通过裁剪解决的只是前四种;第五种靠裁剪解决不了,只能靠改位置——把关键信息移到上下文开头或结尾、用检索把它重新拉回来、或者改写成更显眼的形式。

还有一个容易忽略的推论:裁剪本身也可能制造第五种问题。如果裁掉的恰好是模型需要在中间定位的那条信息,剩下的请求依然是"合法"的,宿主不会报错,但结果质量会下降,而排查方向会误导到"模型能力不足"上。

第四种"信息未读入"也有一个容易看错的细节。用户把文件拖进对话框、或者在消息里 @ 了一个路径,这只是把内容放进了这一轮的用户消息。它和"工具执行后返回的结果"不是一回事——前者的内容由宿主直接转交,后者是模型主动请求、宿主执行后回填的。前者进了上下文,但它是用户提供的材料,不是这次任务实际验证过的证据。

所以"文件明明发过去了,模型却说读不到"这类描述,需要先分清是哪一种。判断方法是看工具执行记录:如果没有对应的读取工具调用,那么模型并没有读过这个文件,它只是看到了你粘贴的内容,而它据此做出的关于文件内容的断言,来源是粘贴的文本而不是文件本身。

按顺序排查,不要跳步

面对"模型没找到",有一个可以照着走的顺序。它不是检查清单,而是一条推理链——每一步都在排除上一层的可能。

步骤要确认的事在 Pi 里怎么看
1
这次请求的输入侧总量是多少
JSON 事件流里 message_update 的 usage.input
2
输入里有没有你以为的那些东西
系统提示的具名段落、工具声明、会话投影
3
有没有发生过压缩
事件流里的 compaction_start / compaction_end
4
这次响应是不是被截断的
响应的结束原因,以及有没有那条工具调用未执行的报错
5
信息在请求里的位置靠前还是靠后
请求的实际排布

第 1 步就能排除掉一大类猜测:如果 usage.input 只有几百,那问题多半不是"上下文太长",而是信息根本没进去——继续往容量方向排查是浪费时间。反过来,如果 input 接近窗口上限,那么第 3 步就是重点:上下文很可能已经被压缩过,模型看到的是摘要而不是原文。

第 2 步值得多说一句。设置页上写着"已加载某个文件"或"已启用某个工具",都不等于这一轮真的发了过去。宿主在运行时组装请求,界面展示的是配置状态,请求内容才是事实。

第 5 步是第五种现象专属的。到这一步,其他四种基本都排除了,剩下的解释就是位置问题。这时候该做的不是继续加内容,而是把关键信息往前挪、或者用检索重新把它拉回来。

输出截断时会发生什么

输出截断值得单独说,因为它的后果比"回复短了"严重。

当一次响应触及输出 token 上限,而这次响应里带着工具调用时,Pi 会拒绝执行全部工具调用,并返回这样一条错误:

text
Tool call "bash" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.

也就是说,那次响应里模型写了一半的 bash 命令不会真的跑。如果参数恰好被截断在危险的位置——命令写到一半、路径写了一半——一个"看起来执行了"的操作其实根本没发生。判断一次工具调用是否真的执行过,仍然要回到第 1 篇说的那个方法:看执行痕迹,不要看模型怎么描述。

这条错误里 "Re-issue the tool call with complete arguments" 的意思是让模型重新发起一次完整的调用。宿主不会替你补全参数,因为截断的参数无法安全还原。

长窗口不等于高质量上下文

窗口越大,能塞进去的东西越多,但塞得多不等于用得上。Anthropic 在讨论上下文工程时用了一组术语来描述这种衰减,用词很值得记住:

  • attention budget(注意力预算):把上下文比作人的工作记忆容量,"LLMs have an 'attention budget' that they draw on when parsing large volumes of context",每多一个 token 都在消耗这份预算。
  • context rot(上下文腐化):上下文变长时,模型准确召回信息的能力反而下降,"as the number of tokens in the context window increases, the model's ability to accurately recall information from that context decreases"。
  • context pollution(上下文污染)与 information relevance(信息相关性):文章判断,无论窗口多大,"context windows of all sizes will be subject to context pollution and information relevance concerns"。
  • loss of subtle but critical context(细微但关键上下文的丢失):压缩时如果裁得太狠,"overly aggressive compaction can result in the loss of subtle but critical context whose importance only becomes apparent later"——那些当下看着不起眼、事后才显出重要性的条件。

同一篇文章还给出了长任务的三种技术选择,并明确说 "The choice between these approaches depends on task characteristics":

Compaction maintains conversational flow for tasks requiring extensive back-and-forth; Note-taking excels for iterative development with clear milestones; Multi-agent architectures handle complex research and analysis where parallel exploration pays dividends.

压缩(compaction)适合需要反复来回的任务;笔记(note-taking)适合有明确里程碑的迭代开发;多智能体架构适合并行探索有回报的复杂研究与分析。三选一不是风格偏好,取决于任务的形状。

另一种思路是不预先装载,而是运行时按需取回。文章把它称为"及时检索"(just in time):"agents built with the 'just in time' approach maintain lightweight identifiers (file paths, stored queries, web links, etc.) and use these references to dynamically load data into context at runtime using tools."上下文里只保留文件路径、查询语句这类轻量标识,需要时再用工具把数据读进来。

Pi 用的是压缩这一条路线。默认配置下,当上下文占用超过 contextWindow - reserveTokens 时触发,reserveTokens 默认为 16384;压缩时保留最近 keepRecentTokens(默认 20000)的内容,更早的消息换成摘要。摘要请求本身也会消耗一次调用,这部分用量单独计入。

压缩到底替换掉了什么,值得看一次 Pi 自己的示意。压缩前后的会话条目布局是这样的:

text
压缩前:
  entry:  0     1     2     3      4     5     6      7      8     9
        ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
                └────────┬───────┘ └──────────────┬──────────────┘
               messagesToSummarize            kept messages
                                  ↑
                         firstKeptEntryId

压缩后(追加一条 cmp 条目):
  entry:  0     1     2     3      4     5     6      7      8     9     10
        ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┬───┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│cmp│
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴───┘
                                                  └───────────┬───────────┘
                                                       从 firstKeptEntryId 开始保留

模型下一次看到的:
  ┌────────┬─────────┬────┬────┬──────┬──────┬────┬──────┐
  │ system │ summary │ usr│ ass│ tool │ tool │ ass│ tool │
  └────────┴─────────┴────┴────┴──────┴──────┴────┴──────┘

模型下一次真正看到的,是"系统提示 + 摘要 + 保留区间的消息"。原来的条目没有被删除,仍在会话文件里,只是不再发给模型。这一点很重要:压缩丢的是模型当前能看到的内容,不是任务的记录。原始事实始终可查,查不到时是检索的问题,不是记录丢了的问题。

切分点也有讲究。正常情况下,压缩在用户消息的边界切开——一个用户消息连同它引发的所有回合算作一个整体。如果某一整段用户消息跨度本身就超过了 keepRecentTokens,切分点会落在这一段的中间,此时 Pi 会生成两份摘要再合并:一份是此前历史的摘要,另一份是这个跨度前半段的摘要。这么做的原因是,跨越整轮对话的切分会破坏回合结构,让保留部分里的消息缺少对应的用户请求。

用量字段告诉你的事

要核对一次请求实际花了多少,看 Provider 返回的用量字段。Pi 的 Usage 接口(packages/ai/src/types.ts)结构如下:

ts
export interface Usage {
  input: number;
  output: number;
  cacheRead: number;
  cacheWrite: number;
  cacheWrite1h?: number;
  reasoning?: number;
  totalTokens: number;
  cost: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    total: number;
    totalFromProvider?: boolean;
  };
}

有两个细节值得单独指出,它们直接决定了对账单怎么读。

第一,reasoning 是 output 的子集,不是并列项。 源码注释写得很明确:"This is a subset of output: output already includes these tokens."所以"思考 token 要单独算钱"这个说法,在账单上成立——思考 token 按输出单价计费;但对字段求和时不能把它再加一遍,否则总数会虚高。同理,cacheWrite1h 是 cacheWrite 中按一小时保留写入的那部分。

第二,cost.total 带一个来源标记。 totalFromProvider 为 true 表示这个总额是 Provider 直接报上来的实收金额;为 false 或缺失时,表示它是 Pi 用本地价格目录算出的估算值。所以同一个 total 字段,可能是账单数字,也可能只是估算——判断方法就是看这个标记。这也是"估算价格与真实账单不是同一件事"在数据层面的具体落点。

cacheRead 和 cacheWrite 是另外两个常被忽略的字段:前者是这次请求从 Provider 提示缓存里读回的部分,后者是本次新写入缓存的部分。缓存读回通常比普通输入便宜得多,所以同一个 input 总量,命中缓存和没命中缓存是两笔完全不同的开销。判断"这次请求贵在哪",要先看缓存命中情况,再看总量。

一个可以自己跑起来的锚点

Pi 的 JSON 模式会把内部事件按行打印出来,长任务里最值得盯的是压缩事件:

bash
pi --mode json "Review this repository"

当上下文接近阈值时,事件流里会出现这样一条记录:

json
{
  "type": "compaction_end",
  "reason": "threshold",
  "result": {
    "summary": "Summary of conversation...",
    "firstKeptEntryId": "abc123",
    "tokensBefore": 150000,
    "estimatedTokensAfter": 32000,
    "usage": {"...": "..."},
    "details": {}
  },
  "aborted": false,
  "willRetry": false
}

tokensBefore 是压缩前的上下文占用,estimatedTokensAfter 是压缩后的估算值。上面这组数字说明压缩把上下文从 15 万压到 3.2 万,代价是中间那些消息的原文被换成了一段摘要。reason 字段区分触发原因:"manual"、"threshold"(超过阈值)、"overflow"(Provider 报了上下文溢出)。

aborted 和 willRetry 也要一起看。压缩被中止时 result 字段整个不存在,aborted 为 true;成功触发溢出恢复时,willRetry 为 true,表示 Pi 会用压缩后的上下文重试之前那条请求。这两个字段决定了"这次请求最终有没有成功"——只看 compaction_end 出现了是不够的。

同一份事件流里,每个助手响应还带一个 usage 字段,那是 Provider 上报的本次响应累计用量。message_update 事件会持续更新它:

json
{"type":"message_update","usage":{"input":100,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":101,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello "}}

注意 input 是这次请求的输入侧总量——系统提示、工具定义、历史和工具结果全在里面。想知道"上下文为什么这么快就满了",这个数字比界面上的百分比更直接。

交互模式下,状态栏会显示当前上下文占窗口的比例和窗口上限;/session 命令可以查看会话文件、消息数、token 用量和费用。

把两种视角对照起来看,判断会更准:状态栏的比例告诉你"离上限还有多远",事件流里的 usage.input 告诉你"这次具体装了什么"。前者适合日常盯容量,后者适合定位原因——尤其是当两者对不上的时候(比例不高但 input 暴涨,或者比例很高但 input 平稳),说明占用主要来自系统提示和工具声明这类固定开销,而不是对话本身。那种情况下继续删历史没有意义,该调整的是工具集和固定前缀。

给长任务保留可恢复来源

工具结果可以摘要,历史可以压缩,但摘要应保留路径、观察时间、重要结论和未完成事项。原始文件或其他权威来源仍要能找到,必要时重新读取。

对源码任务来说,昨天的摘要不能证明今天的文件内容。把容量管理与事实来源分开,既能控制请求大小,也能避免把压缩后的旧结论当成永久事实。这也正是"及时检索"比"预先塞满"更可靠的地方:标识符指向的是真实来源,不是它的副本。

下一篇会把这个思路具体化:同一个任务,怎样选择规则、材料和输出要求,而不是继续增加无关文字。

小结

容量问题可以从四个方向同时收紧:减少每次请求的内容(选更少的工具声明)、把内容换到更容易被用到的地方(重排与检索)、让宿主少发东西(压缩与裁剪)、以及认清哪些限制本来就在 Provider 侧(字节数上限、输出上限)。

判断"模型找不到"的时候,先分清五种原因,再决定查哪里。前四种会留下痕迹,可以顺着请求构建过程往回追;第五种没有错误,只有结果不对——这类问题只能靠改变信息的位置来解决。

资料依据