ysoseri.us

实习日志 / 中文 / 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 token1
字段修正或确认≤ 2,200 token≤ 220 token1
第三方服务建议≤ 2,500 token≤ 250 token1
证据或检索查询每轮 ≤ 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无工具,固定的窄 schema1普通报案、回答、修正、确认
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 是错误的方向。那样会把正在消除的膨胀问题重新请回来。

先做哪一步

实施的顺序按"先看清、再动刀"排:

  1. 在旧版本上先加观测模式,按服务商分别记下提示词、schema、上下文、历史、工具、实际 token、缓存和延迟,形成基线;
  2. 删掉代码里追加的那段重复约定,取消普通回合强制的 claim.read。这一步最确定,延迟收益也最大;
  3. 建立请求配置和服务商能力的注册表,先让工具和 schema 的前缀稳定下来;
  4. 加片段清单、候选目录、确定性路由和分层缓存布局,先以和旧版等价的行为运行;
  5. 加上下文规划和预算报告,先在影子模式下比较"计划输入"和"现有输入",不影响用户;
  6. 启用片段、窄 schema、硬预算和完整的加载决定日志;
  7. 引入滚动摘要、有界的历史引用和后台压缩;
  8. 对跨案件、深度证据和长材料的任务启用隔离执行;
  9. 发布完整的 v7,把旧版留作版本化的回滚目标,它不参与任何自动回退。

验收也分开算:普通路径百分之百只调用一次模型,输入 P95 不超过 3,000 token,总延迟 P50 小于 5 秒、P95 小于 10 秒,没有重复的案件读取,不带用不上的工具、服务目录或险种片段,超预算时停下,绝不静默截断。同一个请求配置在同一版本下的静态前缀字节完全一致,前缀指纹可以复现;被发现、被选中、被装载但没被授权的工具,绝不执行。

还有一条我特意写进了验收:如果输入和调用次数都已经达标,Qwen 的 P95 还是到不了 10 秒,那就明确归为模型吞吐或部署能力的问题,不能靠继续删掉必要的业务信息来伪造性能的改善。

回到那九千个 token

这件事改变了我看提示词的方式。提示词只是上下文里的一类资源,案件、历史、证据、字段合同、第三方服务、工具定义,都应该遵守同样的按需装载和预算规则。优化的目标也从来没有定成每轮 token 最少,要的是在正确、有权限、可追溯的前提下,让普通路径只调用一次模型,保持高信息密度和稳定的缓存前缀,并且让每一次额外的加载都能说清楚原因、来源、预算和授权。