LLM Agent 的上下文管理实践:文件、图片与指令标记的协调
在 LLM Agent 应用中,如何让模型理解用户上传的文件和图片,又不会导致 token 爆炸?本文从上下文管理视角,深入分析文件折叠重读、多模态分流以及工具与指令标记隔离的设计与实践。
把一份 200 页 PDF 或多张高清图片拖进聊天框,模型为什么不会”看不到”或”被上下文撑爆”?当多轮对话展开、甚至用户中途切换模型时,这些附件和工具指令是如何被高效协调的?这些都属于 LLM Agent 应用里极具挑战的一块——上下文管理(Context Management)。本文将从设计原则出发,系统讨论文件分片、图片多模态分流以及特殊工具/指令标记隔离的工程取舍。
模型究竟是怎么”看到”附件的?
这是整套设计的根本问题。LLM 的上下文窗口本质上限制的是它一次能接收的 token 序列:文本先经由 tokenizer 切分成 token,再转换为一串数字输入模型。文件本身是磁盘上的字节,模型不能”直接打开”;所有”模型看到了附件”的体验,背后都是某种把文件内容转换成文本、注入到 prompt的机制。
最朴素的方案:用户上传 → 客户端解析成文本 → 拼进 user message → 每轮请求都重发。
这条路立刻撞墙:
- 200 页 PDF 抽出来约 8 万 token。对话到第 5 轮,光附件就 40 万 token——这个量级本身已经超过多数模型的实际可用窗口,即使理论上标称 1M 上下文的模型,在超过约 128k 之后注意力质量也会明显下降(所谓「needle in haystack」问题)。
- 成本爆炸。输入 token 是要付费的,每轮都重发 40 万 token,几次对话下来账单就不可接受。
- 响应变慢。Prompt 越长,首 token 延迟(业内常记作 TTFT,Time To First Token)越长,生成吞吐也同步下降。
- 本地缓存爆炸。云端同步每条消息都背 80k token 的全文,几个对话下来 localStorage 就满了。
ChatGPT、Claude、Gemini 都不这么做。它们的共同模式是:
- 服务端持久化文件,客户端只拿到一个 id(文件本身存在服务端,本地不再保留二进制)
- 上下文里只放引用——不是全文,也不是像素
- 历史轮折叠成轻量标记
- 模型按需调用工具重读
接下来要回答的是几个具体子问题:什么时候该展开?展开多少?历史轮怎么办?切了模型怎么办?
一、首轮:不是所有附件都走同一条路
IMPORTANT
核心原则:首轮策略按文件类型分层,不是一刀切。 小文件直接 inline 全文;大文档只发指针,首轮就靠工具调用重读。无论哪种,第二轮开始都必须折叠成轻量引用。
很多人想象的”首轮策略”是:模型第一轮必须直接看到全文,不能要求它先调一次工具再回答——因为用户最讨厌”明明上传了,AI 却问你在哪”。
这个原则只对小文件成立。一旦附件是 200 页 PDF 或 50MB 的 PPTX,“首轮 inline 全文”立刻撞墙:
- 首轮就吃掉大半上下文预算:一份 PDF 抽出来约 8 万 token,第一轮就占用这么多,后续几轮对话的空间被压缩到几乎没有。
- 解析本身在客户端做不可靠:浏览器里跑 PDF parser 既慢又占内存,复杂格式(PPTX 嵌图、XLSX 多 sheet、ZIP 嵌套)经常崩。权威解析必须放在服务端。
- 客户端拿不到全文:从架构角度,前端不应该持有大文件的全部解析结果——服务端才是唯一可信源(SSOT)。
所以正确的首轮策略是分三层:
| 文件类型 | 首轮上下文里放什么 | 模型行为 |
|---|---|---|
| 纯文本 / 代码(几 KB) | 全文 inline 进 user message | 0 次工具调用,直接回答 |
| 小型文档(够小、客户端能稳定解析) | 全文 inline | 0 次工具调用 |
| 大文档(PDF / DOCX / EPUB / PPTX / XLSX / ZIP) | 只发 fileId 指针 + 工具提示 | 首轮就调一次 file_read |
1.1 小文件:全文 inline 的理由
纯文本、代码、小型 Markdown 这类文件,本身解析零成本(就是字节本身),inline 进 user message 让模型一次看全,省掉一次工具往返。
[Attached File: a.txt] (stored fileId: file-1)
完整的文本内容...
完整内容的最后一行
---
用户的问题
这是 UX 最好的路径——用户上传 .js 想让模型 review,模型立刻能讨论,不需要等一轮 tool round。
1.2 大文档:只发指针是经济性的必要妥协
对于 PDF 这类格式,首轮 inline 全文既不经济也不可靠。正确做法是:
-
上传时只发字节:浏览器把原文件上传到服务端,服务端运行权威解析器(含 anydoc、unpdf、SheetJS 等),把抽出来的正文写到 extract sidecar。
-
首轮 user message 里只放指针:
[Attached File: paper.pdf] (stored fileId: file-abc) (content is stored server-side in the extract sidecar; to inspect it, call file_read with file_id=file-abc) --- 用户的问题 -
模型首轮看到指针,决定是否调
file_read:如果用户问的就是这份文档的内容,模型必须发起一次工具调用去取。
这不是”失败的 UX”,而是经济性的必要妥协。强求首轮 inline 8 万 token,会让后续几轮对话空间被压死;把解析放在客户端,会让浏览器在各种边缘格式上崩溃。承认这一层妥协,是工程上诚实的态度。
NOTE
主流产品也都分层。ChatGPT 对小文本直接 inline,大 PDF 走 Files API + 内置 retrieval;Claude 几乎所有 PDF 都靠 retrieval 工具;Gemini 对大文档同样靠检索。没有任何主流产品会把 200 页 PDF 的全文塞进首轮 prompt。
1.3 边界判断:多大算”大”?
真正的判断标准不是文件大小本身,而是两个组合因素:
- 解析成本:浏览器能不能稳定解析?复杂二进制格式(PDF / Office)即便小,也不应该在客户端解析。
- token 占用:inline 后会不会吃掉一半以上上下文预算?经验上,几万字符(约 1-2 万 token)以上就不应该 inline。
实践中,按文件类型一刀切(“文本类 inline、文档类发指针”)通常足够好;细粒度的字节阈值反而是过度设计。
1.4 历史轮统一折叠
不管首轮是全文还是指针,第二轮开始都必须折叠——把附件 block 替换成轻量引用占位符:
【历史文件引用】
- paper.pdf (fileId: file-abc): 抽出来的前 400 字预览...
如需全文请调用 file_read(传入 file_id)。
折叠后只占几百 token,不会拖累后续每一轮的 prompt。模型还知道这个文件存在,能引用它的内容;真要细看时,通过工具调用按需重读。
二、标记与指令的协调:协议、占位符与伪调用
在 LLM Agent 应用中,上下文绝不仅仅是文本的堆叠,它充斥着系统控制、模型输出与底层 Tool Call 的交织。如果缺乏清晰的隔离机制,系统极易发生标记混淆与语义污染。
flowchart TD
subgraph Context [上下文 Messages 数组]
direction TB
P1[1. Function Call Protocol<br/>API 结构化 tool_calls 字段]
P2[2. Context Directives<br/>系统注入的引用占位符]
P3[3. Pseudo Tool-Call Text<br/>模型在 content 自行输出的文本]
end
P1 -->|底层协议执行| Exec[触发真实 Tool 管道]
P2 -->|Prompt 引导| Model[告知模型资产存在性与操作能力]
P3 -->|严格 Gate 隔离| Filter[屏蔽: 不记入正文缓存 / 不触发 Tool]
Filter -.->|如果不加隔离| Error[后果: 虚构正文覆盖真实数据 / 引起多次伪重读]
2.1 三类标记的语义区别与生命周期
在包含附件引用与工具调用的多轮对话中,上下文中实际上存在三类完全不同的标记:
- 原生 Function Call Protocol:API 级别的结构化
tool_calls字段(包含call_id、name、arguments),由模型在协议层发起,系统底层捕获并执行。 - 系统指示符 / 上下文占位符 (Context Directives / Placeholders):系统在 Message 中注入的轻量级 Markdown 标记(例如
【历史文件引用: file-abc】),用于告诉模型文件的存在性、预览片段以及当前可调用的工具权限。 - 模型生成的伪调用文本 (Pseudo Tool-Call Text):模型在普通的
content字符串字段中自行输出的类似于(请调用 file_read 读取 file-abc)或[file_read(file_id="abc")]的非结构化描述。
这三者在生命周期和系统处理逻辑上有着本质差异:
| 标记类型 | 产生源 | 位置 | 系统的处理方式 |
|---|---|---|---|
| Function Call Protocol | 模型协议层 | message.tool_calls | 调度工具执行,结果写入 role: "tool" 消息 |
| Context Directives | 系统控制层 | message.content (System/User 注入) | 参与历史折叠,引导模型认知,作为结构化引用的稳定锚点 |
| 伪调用文本 | 模型生成层 | message.content (Assistant 文本) | 必须被 Gate 拦截:既不能触发工具执行,也不能被当作文件正文缓存 |
2.2 伪调用的产生根源与多门控(Multi-Gate)消歧防线
在生产实践中,参数量较小或未充分对齐 Function Calling 协议的模型,往往不能稳定触发结构化的 tool_calls。这类模型倾向于在普通回复文本里生成一段“假装自己在调用工具”的描述。
如果上下文管理在扫描历史轮次、提取文本或做增量缓存时没有识别出这种伪调用,就会酿成严重事故:扫描程序将模型生成的假装调用文本误判定为文件的解析正文并缓存。当后续模型真正触发结构化 file_read 重读该文件时,系统会从缓存中直接读取并返回这段“假装调用的虚构文本”——导致模型永远无法获取到真实的文件数据。
为了防止伪调用污染上下文,系统必须构建一套严格的多门控(Multi-Gate)消歧机制。判定规则必须故意做得很窄,只有同时满足多个极苛刻的条件才算作指令性占位符,从而避免误伤真实的用户文本:
- 包裹性判定:必须由特定的括号/界定符完全包覆。
- 长度与单行限制:字符数限制在极短范围内(如 < 200 字符),且不能跨行。
- 关键词强匹配:包含明确且固定的工具指令标记(如
call file_read)。 - 无 Markdown 格式污染:不能包含表头、管道符
|、标题符#或代码块反引号`。
案例: 我的工程实现中设立了 5 道 Gate 防线(括号闭合 + 长度 < 200 + 关键词命中 + 无换行 + 无 Markdown 标记)。5 个条件全过才认定为系统占位符;任何一道条件未满足,均作为普通回答文本对待,绝不混入文件正文缓存链条。
2.3 能力感知型占位符(Capability-Aware Directives)
上下文占位符不仅是“告诉模型这里有个文件”,更重要的是诚实地向模型传递该文件在系统层面的能力边界。
在实际项目中,文件 id 的持久性等级往往是分层的:
- Durable ID:真正存储在持久化数据库/文件系统中的文件,用户在后续对话中具备编辑(
file_edit)、覆写或重新导出的完整能力。 - Ephemeral / Local ID:只在当前 Session 或临时请求中有效的句柄(如学术论文搜索结果预览、书籍章节临时缓存)。一旦会话结束或超时,该句柄即失效。
如果系统在生成上下文占位符时对模型“过度承诺”(例如给 Ephemeral ID 添加了允许编辑的提示),模型在后续对话中就会不断尝试发起 file_edit 工具调用,进而收到失败报错,陷入无效重试循环;反之,如果“承诺不足”(明明是 Durable ID 却告诉模型不可编辑),模型就会绕远路重新创建同名文件,造成资产冗余。
因此,占位符的生成逻辑必须感知底层资产的能力形态,动态组合不同的 Footer 提示语(如全 Durable 模式、Local 模式或混合模式),确保模型对上下文能力的认知与系统底层能力完全对齐。
三、模型怎么按需重读?
按需重读的本质是:把”展开全文”的决定权交给模型,同时施加有界约束。模型通过工具调用指定想读哪个文件、从哪开始、读多少,服务端返回一个有界的窗口而不是整本书。
为什么必须有界
一次工具调用的返回内容会原封不动地进入下一轮 prompt——返回太长等于把”每轮重放全文”的问题换了个位置重新出现。
窗口大小的取舍有几个考量:
- 太大:单次工具结果就把上下文塞满,模型没有空间思考和回答
- 太小:模型需要多次调用才能看完一个章节,浪费 tool round(通常一轮对话只有 3-5 次工具调用机会)
- 经验值:约 8 个内容单元(页/幻灯片/sheet)、约 2.8 万字符是一个合理的平衡点——够模型看到足够的上下文做出判断,又不至于塞满窗口
模型还可以通过 focus 参数指定关键词,服务端定位到包含该词的页面并返回它附近的窗口——避免模型必须从头翻起。
异构格式 → 统一接口
这是一个关键的架构抽象。PDF、PPTX、DOCX、XLSX、ZIP、EPUB 在底层完全不同——PDF 有页、PPTX 有幻灯片、XLSX 有 sheet、ZIP 有成员。但如果每个格式都有自己的切片逻辑,加新格式就要改核心代码。
正确做法是在更早的阶段把所有格式序列化成同一种中间表示(比如统一的分页标记),之后切片器只对页号操作,不需要知道源格式。加新格式时只写新的 serializer,切片逻辑完全不动。
这个抽象的价值不止是代码复用——它还让”页”这个概念成为跨格式的通用单位。模型不需要知道自己在读 PDF 还是 PPTX,只需要说”从第 3 页开始读 8 页”。用户问”第 5 页讲了什么”时,无论底层是什么格式,体验是一致的。
案例: 我把所有格式统一序列化成 --- page N --- 的分页标记格式。切片器只认这一个标记——这让我后来加 EPUB、PPTX 支持时,工具本身一行代码都不用改。
默认跳过目录
PDF 类文档的开头通常是目录、版权页、序言——这些对回答问题没什么用。默认行为应该是自动跳过,从正文开始读。
跳过策略的优先级应该是从最权威到最启发式:
- 用户显式指定了起始位置 → 尊重用户
- PDF outline 提供了正文起始页 → 用它(最可靠的结构化信号)
- 启发式扫描前面几页,找目录特征
- 容器类(ZIP / PPTX / DOCX / XLSX)的 catalog 页默认跳过
第 3 步的启发式判定本身不平凡。目录页有几种典型形态:
- 有
Contents/目录标题 + dot leader(Introduction……6) - 有 trailing page number(
Title 12) - 有 numbered entry(
1.1 Introduction 6) - PDF 抽取后目录被压成一行(没有真实换行),需要按编号模式重新切分
单一信号不够可靠——一份正文碰巧在第一页列了几个引用也会被误判。需要组合多个信号打分:heading 存在 + 命中比例够高 + 页面总长在合理范围内。
用户想读目录时需要显式表达(比如 start_page=1 或 focus="目录")——默认行为是跳过,不是保留。
异步解析的就绪判定
文件刚上传完,服务端可能还在解析(一份大 PDF 可能要几秒)。这时模型调工具会拿到一个”还没准备好”的信号。
这里有个微妙的平衡:
- 不能直接返回失败——否则模型会把所有工具轮次(通常只有 3 轮)都烧在空转上重试
- 不能等太久——chat 请求有超时限制(通常几十秒到几分钟)
- 不能假装成功——返回 directive pointer 或旧缓存当正文,模型会基于错误内容回答
解法是 短等 + 提示:先做一次有界轮询(几秒内查几次),还没就绪才返回一个明确的”稍后重试”信号,并告诉模型等服务端解析完下一轮再试。这样模型不会在同一轮里反复重试。
案例: 我的实现里,解析就绪的判定有三个充分条件(任一满足即可):sidecar 标记为完成且有正文、标记需要 OCR(光学字符识别,对扫描件进行文本提取)、有 image page 列表(EPUB/PPTX 嵌图)。轮询的错误码也要分类——404 / 鉴权失败立刻返回,超时 / 限流继续等。如果 partial 已为 false 但三个条件都不成立,返回明确的 EXTRACT_EMPTY 而不是 pending。
并发去重
如果有多个调用方(预览面板、上传预热、模型工具调用)同时等待同一个文件的解析完成,不能让它们各自轮询——服务端会被打 N 倍。
这是一个普遍的工程模式——任何”多个 caller 等同一个异步结果”的场景都应该用共享 promise + 引用计数。第一个调用方创建轮询,后续的复用同一个 promise,每个调用方的取消信号只减少自己的引用计数,最后一个调用方离开时才真正取消整个轮询。
这个细节避免了很多生产里的”为什么同一个文件被解析了 3 次”问题。
三、图片:模型能看图吗?
附件分两类:文档(文本可抽)和图片(文本不可抽)。图片的问题更微妙,因为不是所有模型都能看图。
要看图,模型得是多模态的——业内一般叫 VLM(Vision-Language Model),同时处理像素和文本。只能处理文本的模型则需要别人先替它看一眼,再把描述当文本读进去。
视觉能力判定
判断当前模型是否具备视觉能力,通常靠两层:
- 显式 catalog——维护一个已知视觉模型的清单
- 家族启发式——名字带
vision/omni或属于已知多模态家族的,按视觉模型处理
第二层是因为新模型频繁出现,catalog 永远滞后。启发式作为兜底,保证新模型上线时不会被错误地当文本模型处理(导致图片被白白转写降级)。
判定结果驱动整个请求管线的分支:
flowchart TD
U[用户附带图片] --> S[上传得到 fileId]
S --> M{当前模型支持视觉?}
M -->|是| V[服务端把 fileId 展开为 image_url 像素]
M -->|否| T[服务端自动转写注入]
V --> C[多模态 completion]
T --> C2[纯文本 completion<br/>上下文是描述而非像素]
两条路径的取舍
视觉模型路径:直接吃像素。把最新用户轮的文件 id 展开成 image_url(API 调用里一种带图片数据的结构),多模态 completion 直接处理。不走图片理解工具——否则双计费(一次给视觉模型处理图片,一次给工具转写)。
文本模型路径:服务端预处理转写。
IMPORTANT
转写这一步必须服务端自动做,不能让模型先调工具再转写——否则模型第一轮只能看到 (image) 占位,会先空转一轮调用图片理解工具才能回答。
转写完的描述注入回 user message,模型像读普通文本一样读图。旧的、没转写过的图折叠成轻量引用 + 文件路径,模型按需调用图片理解工具重读——大多数旧图根本不会被问到。
转写结果只持久化一次
每张图只调一次视觉模型。转写结果以特殊标记写进 user message content,下一次发请求时识别到这个标记直接走快路径,不再调视觉 API。
这里有两个生产里真实会触发的 bug:
- Dedupe:流式事件和 rewrite 同时跑时,user message 偶尔会被塞两份转写。保留第一份、删掉后续重复。
- Marker 兼容:转写标记的措辞可能因为版本迭代而变化(“由视觉模型转写” vs “已转写”)。识别正则要兼容历史措辞,否则旧会话的图会被当成”没转写过”重新调 API。
多图 batch
用户一次上传多张图时,应该批量调视觉模型(一次 API 调用处理多张),而不是一张一张过。但 batch 有两个工程难点:
- 超时增长:1 张图假设 45 秒预算,每多 1 张加 15 秒,同时设一个上限(例如单张 api 超时 45 秒、批量封顶 90 秒)。线性增长而不是固定值。
- 拆分:batch 返回的文本可能用编号 marker(
【图1】… 【图2】…)包了多张图的描述,需要按 marker 拆分成每张图的独立描述。如果模型没用 marker,fallback 是把整段文字复制 N 份——宁可重复也不要丢。
四、用户切换模型时,之前的转写怎么办?
这是工程里最容易出 bug 的地方。考虑这种序列:
Turn 1: 用户上传图 A,用文本模型 → 服务端转写,注入描述
Turn 2: 用户切换到视觉模型,继续聊
Turn 1 的 user message 里已经有”已转写”描述但没有像素。视觉模型本来应该能直接看图——你不能让它读文本描述就算了,那是降级。
这个问题的解法指向一个普适的设计原则:文件 id 是稳定锚点,转写结果只是缓存。
- 文件 id 始终保留在消息里(作为归档引用)
- 视觉模型路径识别到文件 id 时,重新展开像素——即便那一轮之前是文本模型处理的
- 文本模型路径识别到文件 id 时,复用已转写的描述
换句话说,同一个文件 id 既能走像素路径也能走转写路径,由当前模型决定。转写结果只是个缓存,不是绑死——模型切换时缓存可以绕过。
已生成图的「接力」
OpenAI 兼容 API 有一个普遍的约束:assistant turn 不能携带 image_url。模型生成了一张图,存到 assistant message——下一轮请求时,视觉模型理论上应该能看见自己刚生成的图,但 API 不让。
这个约束不是 bug,是 API 设计的安全取舍——但它在应用层制造了一个gap。解法是把 assistant 的图挂到一个 pending 队列,下一个 user turn 来时 prepend 进去。
但只对视觉模型这么做——否则文本模型的图片理解工具会把生成图当成新上传,又转写一遍。一行代码定方向:是否 carry,取决于当前模型是不是视觉模型。
视觉模型路径还需要一段额外的提示词,告诉模型”这些是已生成的图,不要当成新上传,也不要去找 web 替代品”。听起来像废话,但实际不说的话,模型会经常报”图片生成失败,让我从网上找一张”——把生成图当成失败产物。
五、单一视觉后端为什么不够?
视觉模型在生产里靠不住——限流、超时、CDN 拦截、空响应都会撞上。
这不是选哪个厂商的问题,而是一个普遍的工程原则:任何依赖单一外部服务的环节,都需要 fallback 链。每一级 fallback 的存在都应该对应一种具体的失败模式——否则 fallback 就是装饰。
“先理解、再 OCR”的分级 fallback
图片理解的一个合理 fallback 架构是 “先理解、再 OCR”:
flowchart TD
Start[图片输入] --> S0[本地代理路径]
S0 -->|避开 CDN 大 body 拦截| OK0{有文本?}
OK0 -->|是| Done0[✅ 本地代理 → 场景理解]
OK0 -->|否| S1[网关 VLM 主力]
S1 --> Try1A[图片理解模型 A]
Try1A -->|失败| Try1B[图片理解模型 B 备胎]
Try1B --> OK1{任一成功?}
OK1 -->|是| Done1[✅ 网关 VLM]
OK1 -->|否| S1B[绕过网关直连厂商]
S1B --> OK1B{成功?}
OK1B -->|是| Done1B[✅ 厂商直连]
OK1B -->|否| S2[文本提取兜底]
S2 --> Try2A[专用 OCR 接口]
Try2A -->|失败| Try2B[通用 VLM + OCR 提示词]
Try2B --> OK2{任一成功?}
OK2 -->|是| Done2[✅ 文本提取]
OK2 -->|否| Split[批量失败 → 逐张重试]
每一级解决什么问题:
| 级别 | 解决什么问题 |
|---|---|
| 本地代理路径 | Edge runtime 不能 POST 多 MB 的 data URL 回主域名——CDN 的 bot fight 防护直接 block |
| 网关 VLM 主力 | 多模态理解:能讲清楚截图里在发生什么 |
| 网关 VLM 备胎 | 主力挂了(限流 / 超时)时另一个视觉模型顶上 |
| 绕过网关直连厂商 | 网关整体宕机时,直接调上游厂商 |
| 专用 OCR 接口 | 没法”理解”,但能精确提取文字 |
| 通用 VLM + OCR 提示词 | 专用 OCR 也挂了的最后兜底 |
| 逐张重试 | 一张坏图拖死整个 batch 是常见生产事故 |
WARNING
OCR 不能排在理解之前。纯 OCR 模型只能转录文字,无法回答”这张截图里在发生什么”或”描述一下这个 UI”。把 OCR 放最前面,所有”看图说话”类问题都会失败。这是一条设计原则,不取决于具体用哪个 OCR 服务。
TIP
逐张重试的价值容易被低估。一张坏图(损坏的二进制 / 不支持的格式)拖死整个 batch 是常见生产事故。Batch 失败时 fall back 到一张一张过,能拿到部分成功的结果——总比全军覆没强。
图片字节预算:渐进降采样
服务端把图片 inline 到视觉 API 时不能直接发原图。手机照片 4MB 起步,base64 后 5MB+,gateway 会拒绝。但也不能硬拒——上传限制通常在 20MB 量级,用户体验上不能上传成功却视觉失败。
方案是软目标 + 渐进降采样矩阵:最长边从某个上限(如 1568px)开始,配合多档 quality 和多档 edge,组合出十几种参数,从”高质 + 大尺寸”逐步走到”低质 + 小尺寸”,找到刚好够小的最优组合。
为什么是矩阵而不是单一阈值?因为图片内容差异极大——文字截图高对比度、大面积纯色,高 quality + 大 edge 就够小;照片细节丰富、噪声多,同样参数会超标。矩阵让每种图都找到最优组合,避免一刀切把文字截图也压糊。
NOTE
为什么是软目标不是硬阈值:硬阈值会让”稍微超一点”的图直接失败,用户体验差。软目标配合渐进降采样,能覆盖 95%+ 的真实图片;剩下压不到的,原样发——让上游 API 自己决定。
六、文件被删了怎么办?
这是工程里被低估的一块。文件被删了,但会话里还残留几十个引用标记,模型下次调工具重读直接报错——用户体验崩盘。
这本质上是一个引用一致性问题——和编程语言里的垃圾回收、数据库里的外键级联是同一类问题:当一个对象被销毁时,所有指向它的引用都要处理。
删除操作要处理三种场景:
1. 单个文件被删
所有会话里指向这个文件 id 的引用都要清理——包括正文里的引用块、折叠后的预览行、图片归档行、未转写图片行等所有形态。一个引用块下面的条目全被清空后,连标记头一起删掉——留着就是视觉垃圾。
不留 sticky ghost 是一个重要的产品决策。以前留 unavailable: true 的方案会让用户反复看到”这文件已删”提示,体验差。直接抹掉 Output 卡片,干净利落。
2. 整个对话被删
判定的依据是跨消息引用计数——扫所有消息里的文件引用,算出”这个对话独占的附件”:
对话 A 引用 [file-1, file-2]
对话 B 引用 [file-2, file-3]
删除对话 A → 只删 file-1(file-2 还被 B 引用,保留)
这本质上是垃圾回收里的可达性分析——从一个对话出发能到达的文件 id,如果从其他对话不可到达,就是可以回收的。
3. 用户 Edit/Resend 一条老消息
最微妙的场景。用户改一条带附件的老消息重发:
Turn 1: 用户上传 A.pdf → assistant 回答
Turn 2: 用户上传 B.pdf → assistant 回答
Turn 3: 用户 edit Turn 2 的消息,把 B.pdf 换成 C.pdf 重发
这时要做的事:
- 保留 Turn 1 的附件(A.pdf 不能动)
- Turn 2 原来的 B.pdf:消息会被丢弃,但文件 id 不能立刻删——模型在前几轮可能还在引用它。等到 truncation 真正发生后再清理。
- C.pdf 走完整的”首轮全文 + 上传”流程——edit 后的这一轮在模型眼里就是”最新用户轮”
IMPORTANT
truncation 不是简单的”删后面”。用户 retry 一条老消息,后面所有 assistant 回答都要丢弃,但用户消息的附件不能跟着丢——那些附件可能被前面更多轮的 assistant 引用过。 真正能删的判定:文件 id 只出现在被丢弃的消息里、不出现在保留下来的任何消息里、也不在其他对话里、也不在 composer 的待上传队列里。
还有一个微妙点:对话级别的引用列表(比如 session 级的 web sources)在计算独占时要故意忽略——否则一个 stale 的引用条目就能”救住”一个其实只在被丢弃消息里出现的文件 id,让该删的文件删不掉。
NOTE
不能 eagery 删。我曾犯过这个错:用户 retry 一下,前端就把旧文件 id 删了,结果模型还在引用,下次工具调用重读全炸。正确做法是”标记 + 延迟清理”——附件生命周期要对齐到对话生命周期,不是单次操作生命周期。
七、总结:三条不变的核心约束
回过头看,所有这些设计都在围绕三条基本约束:
1. 上下文窗口有限,不能每轮重放全文/像素。 最新轮完整,历史折叠成引用 + 文件 id。文档走折叠引用 + 按需工具重读;图片走折叠引用 + 按需视觉工具重读。窗口大小是有界的——单次工具返回不应该塞满上下文。
2. 模型能力不统一,必须分路径。 视觉模型保留像素;文本模型服务端预处理转写。每张图只转写一次。模型切换时,文件 id 是稳定锚点——既能重新展开像素也能复用转写。
3. 附件生命周期必须比单次操作长。 删除文件、edit/resend、truncation 都会动到附件引用。不能 eagery 删(用户 retry 一下就找不到文件),也不能完全不删(孤儿文件 id 慢慢堆积)。延迟清理 + 跨消息引用计数是工程里被低估的一块。
主流产品都是这套思路的实现变体。具体到产品形态会有差异(ChatGPT 把 file 工具暴露给 Pro 用户、Claude 把 retrieval 藏在背后、Gemini 走 RAG),但上下文管理的基本约束是一样的。
这套思路在 Agent 软件的上下文管理里是通用的——不依赖具体产品形态,也不依赖某个特定模型供应商。任何「需要让模型理解用户上传内容、又不能每轮重放全文」的应用都会撞到同样的问题,解法的核心约束都是这三条。