实习日志 / 中文 / Agent 的控制与编排 / 上下文是一种资源:一轮对话 9,000 token 是怎么来的
上下文是一种资源:一轮对话 9,000 token 是怎么来的
中文 | English
9 月中旬,报案端的 Agent 已经能聊,但慢得让人坐不住,一句很简单的问题有时要等八十多秒。我一开始以为是提示词太长,打算把它拆成几十个小文件。动手之前先量了一下,才发现问题比"提示词长"严重得多。
先量,再说
9 月 16 日,我在 main 上按真实的请求组装逻辑,用一张最小的测试报案量了一次普通的对话回合:
| 请求 | 字符数 | 约合 token |
|---|---|---|
| 第一次模型调用 | 15,130 | 约 3,782 |
| 第二次模型调用 | 21,733 | 约 5,433 |
| 一个普通回合合计 | 36,863 | 约 9,215 |
token 是用字符数除以 4 粗估的,换成各家模型的分词器会有出入,但量级足够说明问题。这还是一张几乎空白的测试报案,没有带任何真实的对话历史。
拆开看,固定成本来自这几处:
- 当时的 v6 主提示词约 2,440 token;
- 统一的输出 schema 约 1,532 token;
- 代码在提示词后面又追加了一段重复的"运行时约定",约 614 token;
- 每个普通回合都被强制调用两次模型。第一次带着完整的提示词、案件上下文和一个
claim.read工具,要求模型先把案件读一遍;第二次把以上内容再发一遍,加上读到的结果和完整的输出 schema。而claim.read返回的内容,和第一次调用时已经注入的案件信息大部分是重复的。
还有几处不在这张表里、但迟早会爆的地方。对话历史按"最近 12 条"截取,而一条消息最长可以有 5,000 字符,光历史就可能接近 60,000 字符。知识库返回的条款片段没有尺寸上限。第一次调用带工具、第二次不带,工具集合一变,模型服务商的前缀缓存就很难命中。最后,Bedrock 的适配器当时拒绝普通的工具调用,可这条路径又强制要求 claim.read,所以整个流程在 Bedrock 上根本跑不通,说它"与服务商无关"并不成立。
量完以后,把提示词拆成几十个文件这个想法就站不住了。总内容不变,只是换一种目录结构再全部发出去,一个 token 也省不下来。要动的是每一轮到底往模型里装了什么、调用了几次。
看看别人怎么管上下文
接下来一天,我把 12 类公开系统处理上下文的做法过了一遍,以官方文档和团队公开的工程文章为主。
Kiro 的 steering 文件有四种加载方式:always 每次都加载,fileMatch 在当前文件匹配时加载,auto 按描述和请求的相关性加载,manual 由用户显式引用。它们表达的是四种加载的触发权,和提示词长短无关。可这只是触发分类,直接拿来当业务系统的权限模型就不对了。
Cursor 的规则也分四类,建议规则要聚焦、拆小,引用权威文件,不要复制全文,linter 能机械检查的内容不要再塞进提示词。它的 skills 启动时只暴露名字和描述,正文按需加载;subagent 用独立的上下文窗口处理代码搜索、长日志这类高噪声工作,主 Agent 只收摘要,但官方也提醒,子 Agent 有启动成本,简单任务反而更慢。
Claude Code 把 CLAUDE.md 和记忆当成上下文,不当成强制配置,真要机械地阻止某个操作,官方建议用 hook。用 @path 导入的文件会在启动时整份展开,所以拆成多个导入文件只是让组织更清楚,上下文成本一点没降。
Codex 在一次运行开始时,从用户全局规则一路读到当前目录的 AGENTS.md,合并上限 32 KiB,运行中不再重新扫描。它适合稳定的仓库约束,不适合承载每一轮都在变的业务状态。
Aider 不把整个代码库发给模型,先给一张很小的仓库地图:文件、关键类、函数和签名,默认预算约 1,000 token。模型看地图就能理解主要接口,需要细节再请求具体文件。
Sourcegraph Cody 的文档里有一句我很认同的话:上下文太小会漏掉关键代码,太大会拖慢响应。所以要追求的是相关信息的密度,"越小越好"从来不是目标。
Anthropic 的 context engineering 建议稳定的核心信息预先装载,长尾信息即时取,过长的历史压缩,过期的工具结果移出活动上下文。它同时提醒,压缩过度会删掉后来才显得重要的信息。
Manus 的工程文章把 KV 缓存命中率当成生产 Agent 最重要的单一指标:保持提示词前缀稳定,哪怕差一个 token 都可能让后面的缓存全部失效;序列化要确定、顺序要稳定;尽量只追加不改写;不要在 Agent 循环里频繁增删工具定义;把当前目标在上下文末尾重述一遍,对抗"中间部分被忽略"。这是一家团队的经验,算不上跨模型的协议,但和 OpenAI、AWS 关于前缀缓存的约束互相印证。
OpenAI Agents SDK 的自动压缩会在最后一个输出 token 之后继续阻塞好几秒,需要低延迟时,官方建议关掉自动压缩,在两轮之间或空闲时手动压。Google ADK 有按 token 阈值和按固定轮数两种压缩触发,同时配置时 token 阈值优先。
MCP 的工具规范要求服务端按确定的顺序返回工具,并且工具的可见性要反映当前请求的凭据和权限范围。模型看得到一个工具,不代表谁都能执行它:发现、授权、执行是三道不同的边界。
Amazon Bedrock 的缓存按 tools → system → messages 的顺序处理,前面一层变了,后面的缓存全部失效,所以工具清单一变,系统提示词和消息的缓存也跟着作废。而且缓存命中不是保证的,要从返回的用量里检查;有些模型写缓存比普通输入还贵,只有后面读得够多才真正省钱。
把这些放在一起,能归纳出几条共同的模式:
| 共同模式 | 证据来自 | 对我们的意思 |
|---|---|---|
| 最小常驻核心 | Kiro、Cursor、Claude Code、Codex | 每轮必带的只有安全、权限边界和不变的通用规则 |
| 目录和正文分开 | Cursor、Claude Code、Codex 的 skills,Aider | 常驻片段、工具、服务的元数据,正文按需加载 |
| 确定性条件优先 | Kiro fileMatch、Cursor globs、Cody 的代码图 | 已经知道的案件状态和险种,不再交给语义模型去猜 |
| 语义选择有边界 | Kiro auto、Cursor 智能规则、skills | 语义匹配只能产生候选,不能授予权限,也不能触发副作用 |
| 长内容外置、可回查 | Anthropic、Manus、Aider、OpenAI | 引用要带作用域、选择器、版本、预算和回查原文的路径 |
| 历史用摘要加近期原文 | Anthropic、OpenAI、ADK | 案件状态单独保存,摘要只负责对话的连续性 |
| 高噪声任务隔离 | Cursor、Codex、Claude Code | 深度的历史和证据分析可以隔离,普通回合不用子 Agent |
| 稳定前缀、稳定工具 | Manus、OpenAI、AWS、MCP | 工具、schema、提示词顺序按配置固定并版本化 |
| 预算必须看得见 | Claude Code、ADK、OpenAI、AWS | 记录原始和缓存 token、加载原因、截断、缓存未命中和实际延迟 |
知道它存在,和把它发给模型,是两件事
调研得出的最重要的一句话,我写在了诊断报告的正中间:
"Agent 知道某项内容存在"与"本轮把它的正文发给模型",必须是两个动作。
顺着这句话往下拆,一项内容从被知道到被执行,要经过五个状态:
discoverable -> selected -> loaded -> authorised -> executed
discoverable:系统知道某个提示词片段、工具、服务或引用存在;selected:本轮规划认为它可能相关;loaded:它的正文或 schema 真正进入了模型请求;authorised:当前用户、当前案件、同意状态、可见性和发布策略允许用它;executed:运行时校验过具体参数,真的执行了读取或副作用。
前一个状态永远不隐含后一个状态。语义匹配可以发现定损服务,但不能因此就披露案件数据、取得同意或发出请求;模型选了一个工具,也绕不过运行时的执行授权。把这五个动作混成一个,正是上下文膨胀、Agent 越权和行为说不清的共同根源。
Kiro 的四种模式只覆盖了"怎么触发加载",一个业务运行时还需要有界的引用解析、历史压缩和隔离执行。所以我们用了七种加载模式:
| 模式 | 含义 | 典型内容 |
|---|---|---|
always | 相关配置的稳定前缀必须带上 | 权限边界、安全底线、运行时和模型的责任边界 |
route_match | 路由、险种或状态确定性匹配时加载 | 车险片段、第三方服务任务约定 |
auto_candidate | 语义匹配只产生候选,不能自动授权或扩大上下文 | 第三方服务候选、知识主题候选 |
explicit | 用户或受控界面明确请求时加载 | 指定的证据、指定的历史问题 |
reference | 常驻一个轻量目录项,正文通过有界引用解析 | 长条款、历史消息范围、证据正文 |
compacted | 用可回查来源的摘要替代较早的原文 | 会话历史、较长的工具结果 |
isolated | 在独立上下文里处理高噪声任务,只返回带来源的摘要 | 跨案件历史、深度证据比较 |
auto_candidate 的边界最要紧:它只能给出候选,不能因为一个模糊的相似度就把大段正文装进来,更不能授予权限、同意或副作用。置信度低或候选冲突,就进入澄清,不扩大加载范围。
v7:按需装载的上下文运行时
v7 的定义我写成了一句英文,后来一直挂在设计文档的最前面:
A versioned, route-aware and budgeted Agent Runtime that selects a stable request profile and loads only the prompt fragments and contextual facts required for the current turn, while keeping discovery, loading, authorisation and execution separate.
意思是:一个版本化、知道当前路由、受预算约束的运行时,每一轮先选一个稳定的请求配置,只装载当前任务需要的提示词片段和事实,并把发现、装载、授权和执行严格分开。
一个回合的流水线
Authenticated message
-> Deterministic interrupts
-> Turn Router
-> Candidate Catalogue
-> Context Planner
-> Request Profile Selector
-> Prompt Composer and Cache Planner
-> Model Gateway
-> Proposal Validator
每一段只做自己的事:确定性中断先处理危险、伤情、身份、版本冲突和明确的同意操作,模板化的结果不调用模型;路由只决定险种、任务和路线,不生成答案;候选目录只列出本轮能被发现的东西,不装正文;上下文规划决定哪些内联、哪些以引用出现、哪些省略,并记录原因和预算;请求配置选择器从少数几个已发布的配置里选一个;提示词组装器按稳定顺序编译片段,把静态前缀和动态内容分段;模型网关只执行已经过预算检查的请求,不偷偷追加任何内容;最后由提案校验器对动作、字段、同意、权限、版本和副作用做最终控制。
任何一段都不允许用"为了保险,把全部内容带上"的方式绕过前一段的选择结果。
提示词片段
提示词按职责拆成 core、family、task、capability 四类目录,每个片段在 manifest.json 里有自己的合同:
{
"fragment_id": "family.motor",
"version": "v1",
"path": "family/motor.md",
"kind": "family",
"load_mode": "route_match",
"applies_when": { "product_family": ["motor"] },
"requires": ["core.authority", "core.provenance"],
"conflicts_with": ["family.home", "family.contents"],
"priority": 100,
"max_tokens": 420
}
组装器选一个险种片段、一个任务片段和零到多个能力片段,递归解析依赖,拒绝循环和不存在的片段,检查冲突(车险、房屋、财产三个片段绝不同时进入),按 core → family → task → capability 的固定顺序编译,逐段和整体做预算检查,最后产出一个稳定的 prompt_bundle_id,比如 claimant-v7:motor:intake:external-support。日志里从此记录的是这个 bundle ID 和精确的片段列表,不再是一个笼统的 prompt_id。
文件可以有几十份,但普通回合最好只装 4 到 6 个短片段。拆细的价值在于选得准、能复用、看得清,不在于让模型多读。
路由不用模型
路由是一段确定性的算法:先跑安全中断;案件已经确认了险种就直接用;还没确认的,用分支注册表和受控词汇识别候选险种,只有一个候选就选它,多个或零个就进入"险种未定"路线,由运行时用一句固定的话请报案人选先报哪一类,不额外调一次模型去猜。一旦正式案件已经有了险种,本轮消息里出现别的险种的词也不会重新路由,跨险种的内容要开或切到另一单,免得污染当前案件的提示词和字段合同。路由结果里有一个 model_required 标记,为 false 时运行时直接返回确定性的回复,一次模型调用都不产生。
普通回合只调用一次模型
"先强制读一遍案件、再生成答案"的设计取消了。案件已经在经过认证的消息边界被加载、被授权,再让模型用工具读一遍同一个对象,只会重复传输、多一次往返。普通路径变成:
运行时确定路由
-> 组装最小的提示词和上下文
-> 一次模型调用
-> 运行时校验并应用提案
只有真正缺数据的路径,比如查证据、搜历史、查条款,才允许第二次调用。输出 schema 也拆成几种窄的:普通回答、字段更新、第三方服务建议、证据操作、人工交接、立案,系统自己拥有或能推导出来的字段,不再要求模型重复输出。
预算定成硬数字:
| 路径 | 输入目标 | 输出目标 | 模型调用 |
|---|---|---|---|
| 普通报案或回答 | ≤ 1,800 token | ≤ 180 token | 1 |
| 字段修正或确认 | ≤ 2,200 token | ≤ 220 token | 1 |
| 第三方服务建议 | ≤ 2,500 token | ≤ 250 token | 1 |
| 证据或检索查询 | 每轮 ≤ 3,000 token | ≤ 300 token | 最多 2 |
3,000 是普通交互的硬上限,目标要比它低得多。体验目标是 P50 小于 5 秒、P95 小于 10 秒;模型配置上仍保留 90 到 120 秒的宽松技术超时,那只是为了不误杀偶发的慢请求,不能拿来掩盖性能问题。普通请求超过 10 秒,就记一次性能失败。
每类上下文怎么装
- 案件:只内联案件 ID、版本、险种、当前工作流状态,当前动作需要的字段,本轮最新消息,刚刚变化或冲突的事实,还缺什么、下一项是什么、能不能立案,以及允许的字段取值。
- 对话历史:分四层保存,权威的案件状态、经过校验的滚动摘要、最近 2 到 4 轮原文、更早消息的范围引用。普通回合只发摘要和最近几轮,压缩按 token 阈值触发,轮数只作辅助。摘要不能覆盖案件状态,也不能把没确认的话升级成事实,每份摘要都要能回到原文核对。压缩放在回复完成后的空闲期做,不让报案人等一次摘要模型。
- 证据:默认只给 ID、类型、处理状态、一句报案人可见的描述。图片和文档正文只在最新消息明确提到它、当前任务就是看证据、模型支持这种媒体、预算允许、可见性和保留规则也允许时才装。
- 第三方服务:先按险种、意图、服务状态和同意状态过滤,普通回合最多内联三个候选,每个只给服务标识、对报案人的用途、支持的动作、需要的同意范围、当前状态和下一步。
- 知识、条款、历史案件:不允许按 ID 读整份文档,要带查询或选择器,默认每个结果不超过 300 token、每轮最多 3 个。
长内容进入模型时是一个受控的引用:
{
"ref": "evidence:ev_123",
"type": "damage_photo",
"summary": "Rear bumper damage photo",
"version": 2,
"available_operations": ["inspect"]
}
真要看正文,通过 context.resolve(ref, selector, max_tokens) 解析,每次都必须指定选择器和上限,模型不能要求"读全部"。引用是运行时针对当前用户、当前案件、当前回合生成的,模型不能自己造 ID,也不能用一个引用越过案件、客户、可见性或同意的边界。但这里有个平衡:不能为了省几个 token 就无条件多一次模型调用。运行时已经能判断某段内容是完成任务必需的,就在调用模型之前直接解析好;只有模型在处理中才发现缺东西,才走工具调用。
超预算时宁可停下
每轮按优先级分配预算:
P0 最新消息、权限边界、输出 schema
P1 险种和任务片段、当前动作需要的案件事实
P2 冲突的事实、当前的同意或外部任务状态
P3 最近的对话、匹配的服务、附件元数据
P4 历史摘要、知识片段和其他可解析的引用
P0 到 P2 不能被静默截断。它们超了预算,运行时返回 CONTEXT_BUDGET_EXCEEDED,给出一个安全的确定性澄清,宁可停下来,也不把一份残缺的权威事实交给模型。P3 和 P4 可以转成引用、分页或减少数量,但要记进 omitted_sections。
稳定的请求配置
工具定义有两个极端都不行:所有请求都带完整的工具注册表,浪费上下文、扩大误调用的范围;按每个细粒度的路由临时增删工具,又会改变工具定义这一段前缀,缓存命中率下降,服务商的行为也难以复现。所以只发布少数几个稳定的请求配置:
| 配置 | 固定能力 | 调用上限 | 用在哪 |
|---|---|---|---|
claimant.intake.v1 | 无工具,固定的窄 schema | 1 | 普通报案、回答、修正、确认 |
claimant.media.v1 | 图片或文档输入 | 1 | 理解当前附件 |
claimant.lookup.v1 | 固定的小型只读工具集 | 最多 2 | 条款、历史消息、案件和证据查询 |
claimant.external.v1 | 稳定的第三方服务提案合同 | 1 | 发现第三方能力、发出同意请求 |
claimant.deep-review.v1 | 隔离执行,只返回带来源的摘要 | 独立预算 | 跨案件或多证据的深度分析 |
同一个配置版本只绑定一个 schema,它的工具定义、顺序、schema 和静态前缀都要字节级稳定。路由只决定选哪个配置、哪些内容内联或引用,不现场生成一套新的工具清单;具体某次调用能不能执行,仍由运行时按用户、案件、权限、同意、可见性和版本号授权。每个模型配置还要显式声明自己支持什么:结构化输出的方式、能不能调工具、工具结果怎么续接、支持哪些媒体、缓存是隐式还是显式、最小缓存长度。不满足的配置在选择阶段就拒绝,不等请求发出去以后再退化。
让缓存真的命中
请求的逻辑布局固定成:
provider-required tool/schema segment
global core authority and safety
cache checkpoint A
family/task/capability fragments
cache checkpoint B, optional
dynamic Claim projection
retrieved context
recent conversation
latest claimant message
Turn Focus recitation
静态的在前,动态的在后。不同服务商对工具、系统提示词和消息的物理顺序要求不同,这是逻辑布局,由模型网关的适配器负责落成各家的缓存断点。
最后那段 Turn Focus 借的是 Manus 的办法:只把容易在长上下文里被弱化、又影响本轮输出的几个短字段在末尾重述一遍,包括案件版本、路由、当前目标、下一项必填、允许修改的字段范围、待处理的同意或外部动作。不重复整段安全规则或历史摘要。
也不要为了够上 OpenAI 或 Bedrock 的最小缓存长度去填充没用的提示词,"更短但没缓存"和"略长但稳定缓存"哪个更快更便宜,要拿实测的数据比。
看得见的预算
每次调用都记录一份不含敏感正文的数据:用了哪个发布、哪个请求配置、哪个提示词 bundle 和哪些片段、哪个 schema、哪条路由,每个候选资源的加载决定和原因,原始输入、未缓存输入、可缓存、缓存读写、整轮累计和输出各多少 token,调了几次模型、几次工具,首 token 和总延迟,有没有达到 10 秒的目标。原始 token 继续受 3,000 的硬上限约束,不能因为命中了缓存就允许无限膨胀;未缓存和缓存读写用来判断真实的成本和延迟;整轮累计防止单次请求看着合规、续接加起来却失控。
Qwen、GPT 和以后的 Bedrock 分开报告,不能用跨服务商的平均值掩盖某一个适配器的高延迟或缓存失效。
拿去和业界对照
设计写完以后,我又拿它去和 Anthropic、LangChain、Manus 公开的生产实践逐条对照了一遍。大方向是对的:片段注册表加按需装载,对应 LangChain 说的 "Select" 和 Anthropic skills 的渐进披露;有界引用对应 Anthropic 的 just-in-time;滚动摘要加最近原文对应 compaction;确定性路由符合"能用确定性逻辑解决就不引入模型";P0 到 P2 不截断,本质上是在防上下文投毒。
但对照出了几处缺口,其中一处和最佳实践直接冲突:
最初的草稿是按路由动态暴露工具的。 初衷是避免工具太多导致模型选错,可"逐路由增删工具定义"正是 Manus 踩过的坑:缓存失效,模型也会困惑,上一轮还在的工具这一轮不见了,容易误判。前面那张稳定请求配置的表,就是这次对照之后改出来的:工具定义保持稳定,"本轮能不能真的调用"放到运行时的执行层去校验。
预算表没有区分缓存和未缓存的 token。 缓存命中和未命中的单价可以差十倍左右。只要前缀不稳,哪怕总 token 很少,实际成本和延迟可能比一个更长但前缀稳定的方案还差。所以预算和日志里都补上了缓存的几个字段。
可观测性只有成本维度,没有质量维度。 LangChain 把上下文的失效分成四类:投毒(错误信息被当成可靠事实反复使用,比如摘要把"报案人提到过"升级成了"报案人已确认")、分心(无关内容稀释注意力)、混淆(同时暴露几个相似的候选,比如车险和房屋的片段一起进来)、冲突(不同来源互相矛盾,比如滚动摘要和案件状态打架)。日志里因此加了几个质量计数:摘要和状态不一致、险种片段冲突、解析了过期引用、候选误报。
关键状态应该放在上下文末尾。 这就是后来的 Turn Focus。
深度探索应该走隔离,不该放宽调用次数。 以后真有"跨多份证据比较"这种任务,正确的做法是开一个隔离的子任务,在它自己的上下文里探索几万 token,只把一两千 token 的结论交回来;把每轮的调用上限从 2 改成 4、5 是错误的方向。那样会把正在消除的膨胀问题重新请回来。
先做哪一步
实施的顺序按"先看清、再动刀"排:
- 在旧版本上先加观测模式,按服务商分别记下提示词、schema、上下文、历史、工具、实际 token、缓存和延迟,形成基线;
- 删掉代码里追加的那段重复约定,取消普通回合强制的
claim.read。这一步最确定,延迟收益也最大; - 建立请求配置和服务商能力的注册表,先让工具和 schema 的前缀稳定下来;
- 加片段清单、候选目录、确定性路由和分层缓存布局,先以和旧版等价的行为运行;
- 加上下文规划和预算报告,先在影子模式下比较"计划输入"和"现有输入",不影响用户;
- 启用片段、窄 schema、硬预算和完整的加载决定日志;
- 引入滚动摘要、有界的历史引用和后台压缩;
- 对跨案件、深度证据和长材料的任务启用隔离执行;
- 发布完整的 v7,把旧版留作版本化的回滚目标,它不参与任何自动回退。
验收也分开算:普通路径百分之百只调用一次模型,输入 P95 不超过 3,000 token,总延迟 P50 小于 5 秒、P95 小于 10 秒,没有重复的案件读取,不带用不上的工具、服务目录或险种片段,超预算时停下,绝不静默截断。同一个请求配置在同一版本下的静态前缀字节完全一致,前缀指纹可以复现;被发现、被选中、被装载但没被授权的工具,绝不执行。
还有一条我特意写进了验收:如果输入和调用次数都已经达标,Qwen 的 P95 还是到不了 10 秒,那就明确归为模型吞吐或部署能力的问题,不能靠继续删掉必要的业务信息来伪造性能的改善。
回到那九千个 token
这件事改变了我看提示词的方式。提示词只是上下文里的一类资源,案件、历史、证据、字段合同、第三方服务、工具定义,都应该遵守同样的按需装载和预算规则。优化的目标也从来没有定成每轮 token 最少,要的是在正确、有权限、可追溯的前提下,让普通路径只调用一次模型,保持高信息密度和稳定的缓存前缀,并且让每一次额外的加载都能说清楚原因、来源、预算和授权。