Hotdry.

Article

Agent 多轮上下文装配:tool 消息配对完整性、分层裁剪与摘要压缩

单次 tool 结果预算与 run 级 max_turns 挡不住历史消息线性膨胀。本文给出上下文装配流水线:tool_call/tool_result 配对不变量、分桶 token 预算、从中段删块与可选摘要压缩的可落地表,并说明 prompt cache 与错误 400 边界。

2026-07-21systems

生产 Agent 的一次 run 往往包含多轮「模型 → tool_calls → 回灌 → 再模型」。单次工具结果的字节 /token 预算能压住一步回灌;run 级 max_turns / 墙钟 deadline 能挡住无限环;但二者都不解决另一类故障:合法历史消息随轮次线性膨胀,下一请求直接撞上下文窗口上限,或在裁剪时拆坏 tool 配对导致提供商 400。本文只讨论 把持久化会话装配成下一次模型请求 messages / input 的策略与参数,不重复单工具回灌截断、流式 arguments 拼装、以及 run 预算 / 取消传播的细节(见近期专文)。

问题背景:上下文是有结构的字节预算,不是「字符串越短越好」

典型数据流:

  1. 会话存储持有完整消息序列:system / developer、用户输入、assistant(含 tool_calls / tool_use)、tool 结果、可选中间摘要。
  2. 每一轮模型调用前,编排器从存储装配一份 prompt 视图:可能裁剪、摘要、重排(通常不重排角色顺序)、注入最新策略与工具清单。
  3. 装配结果经 tokenizer 或官方计数 API 校验后发给提供商;失败则降级策略或中止 run。

在缺少显式装配层时,常见故障包括:

  • 历史无界:每轮 tool 结果即使各自 ≤ 8k tokens,20 轮后仅 tool 侧即可超过 100k;再加 system、技能文档与长用户粘贴,费用与延迟线性上升,最终触发 context_length_exceeded 类错误。
  • 配对被拆坏:OpenAI 风格下,带 tool_calls 的 assistant 消息之后,必须有对应 role=tooltool_call_id 匹配的结果;Anthropic 风格下,tool_usetool_result 的 id 必须对齐。中段「只删 tool 结果、保留 assistant tool_calls」或「只删 assistant、保留孤立 tool」都会让下一请求非法。
  • 错误地从头部砍:把最早的 system 或首轮用户目标裁掉,模型会丢失任务约束;把最近 N 轮裁掉,又会丢掉当前工具环状态。
  • 摘要与事实漂移:用另一模型把中间历史压成一段「摘要 user/assistant」后,若未保留「已执行副作用」清单,后续轮次可能重复发邮件、重复写文件,或否认已发生的工具结果。
  • 与 prompt cache 冲突:前缀每轮都因「头部插入摘要 / 重排 system」而变化,导致缓存命中率接近 0,延迟与费用不降反升。

因此应把上下文装配建成带不变量的流水线,而不是 messages[-k:]

load(session messages, run budget snapshot)
  → 保护前缀(system / developer / 固定策略块)
  → 保护后缀(最近完整 tool 环 + 最新 user)
  → 校验并修复/拒绝破损配对
  → 中段候选集按优先级删除或摘要
  → token 复算;仍超限则升级策略或 fail-closed
  → 发出模型请求(可观测:装配前后 token、策略名、是否摘要)

可落地实现:不变量、分桶预算、裁剪与摘要

1. 消息配对不变量(装配前必须成立)

把「提供商能接受的消息图」写成可单测的校验器;任何裁剪 / 摘要之后再跑一遍,失败则不得发请求。

规则 OpenAI Chat Completions / 兼容网关 Anthropic Messages(tool use)
调用侧 assistant 消息的 tool_calls[].id 非空且在本会话内唯一 assistant contenttool_use.id 非空且唯一
结果侧 每个 role=tool 必须带 tool_call_id,且能指回某条历史 assistant 的 tool_calls 每个 tool_result.tool_use_id 指回对应 tool_use
完整性 同一 assistant 上的全部 tool_calls 都应有结果(可并行多条 tool 消息)后再出现下一条 user/assistant 同一轮 tool_use 块集合应有对应 tool_result 后再继续
顺序 tool 结果紧随产生它们的 assistant(中间勿插入无关 user) tool_result 放在 user 消息的 content 块中,紧随含 tool_use 的 assistant
禁止 孤立 role=tool;有 tool_calls 无结果就直接再 user 孤立 tool_result;删掉 tool_use 却保留结果

最小校验伪代码(厂商无关的 id 视图):

from dataclasses import dataclass

@dataclass(frozen=True)
class ToolEdge:
    call_id: str
    assistant_msg_idx: int
    result_msg_idx: int | None  # None = 未回灌

def validate_tool_pairing(edges: list[ToolEdge]) -> list[str]:
    errors: list[str] = []
    seen: set[str] = set()
    for e in edges:
        if e.call_id in seen:
            errors.append(f"duplicate tool id: {e.call_id}")
        seen.add(e.call_id)
        if e.result_msg_idx is None:
            errors.append(f"missing tool result for {e.call_id}")
        elif e.result_msg_idx <= e.assistant_msg_idx:
            errors.append(f"result before call: {e.call_id}")
    return errors

修复策略(按安全优先级):

  1. fail-closed(默认):配对破损则中止本轮模型调用,记录 context_assembly_pairing_error,向用户 / 运维暴露可重试的内部错误,而不是「猜着删」。
  2. 协议修复:仅当破损来自本平台已知 bug(例如并行 tool 中一路超时未写结果)时,为缺失 id 合成 isError=true 的占位 tool 结果(文案固定、不含内部栈),再装配。
  3. 禁止:自动删除 assistant 的部分 tool_calls 却保留其余结果;禁止把多轮 tool 环拍平成一段无 id 的纯文本却仍声称「完整历史」。

2. 分桶 token 预算,而不是单一 max_context

把「模型上下文窗口」拆成互不抢占的桶。窗口以目标模型的输入上限为准(例如 128k / 200k),并预留输出与安全余量:

预算桶 建议占比(起点) 内容 备注
system_budget 5%~15% system /developer、安全策略、工具使用约束 优先不动;超限应裁技能文档而非删安全条款
tools_schema_budget 5%~20% 本轮挂载的 function/MCP tool JSON Schema 工具过多时做名称级检索挂载,而非塞满全量 schema
pinned_budget 5%~10% 用户原始目标、租户策略、已确认的副作用摘要 钉住,裁剪阶段最后才动
recent_budget 25%~40% 最近完整 K 个 tool 环 + 最新 user/assistant 保证「当前在干什么」可续跑
middle_budget 剩余 中间历史;可删可摘要 超限时的主操作区
output_reserve 固定 4k~16k tokens 留给 completion / 下一轮 tool arguments 不计入可装配输入

建议默认参数:

参数 建议起点 作用
model_input_limit 按模型文档 硬上限;装配后 prompt_tokens ≤ limit - output_reserve
output_reserve_tokens 409616384 防止「输入塞满导致输出只剩几百 token」
safety_margin_tokens 5122048 tokenizer 估计误差与提供商额外包装
recent_tool_rings 24 后缀至少保留的完整「assistant tool_calls + 全部 results」环数
recent_user_turns 13 后缀额外保留的最近用户消息数(不含已计入 ring 的)
middle_delete_unit tool_ring 中段删除粒度:整环,而非单条消息
summarize_trigger_ratio 0.750.85 估计 prompt 占 model_input_limit 比例超过后触发摘要或删块
summary_max_tokens 5122048 单次压缩摘要上限
summary_model 小模型或同模型低温 与主模型分离时可降本;需独立超时与失败回退
token_counter 与目标模型一致的 tokenizer 或官方计数 API 禁止用 len(text)/4 作为唯一依据上线

3. 分层裁剪:先整环删除中段,再考虑摘要

优先级从高到低保留:

  1. 安全与策略类 system /developer(含「不可覆盖」条款)。
  2. 当前 run 的 pinned 用户目标与「已执行副作用」清单(若有)。
  3. 最近 recent_tool_rings完整 tool 环。
  4. 中段历史中带 isError 的短工具结果(有利于自纠;可设条数上限)。
  5. 更早的纯文本 user/assistant 闲聊。
  6. 中段成功且冗长的 tool 结果(通常最先删 —— 它们往往已在工作区 / DB 留下真实副作用)。

删除算法(确定性,便于回归):

while estimate(prompt) > budget and middle_has_candidates:
  pick oldest middle tool_ring  # 含 assistant(tool_calls) + 其全部 tool results
  if ring overlaps pinned or recent: skip
  else: drop entire ring as one unit
if still over budget:
  drop oldest middle plain turns (user/assistant without tools), FIFO
if still over budget:
  optionally summarize remaining middle → replace with one system/user note
if still over budget:
  fail-closed or strip non-essential tools_schema / skills

关键点:删除单位是 tool 环,不是单条消息。 这样天然维持配对不变量。

对「纯文本轮次」,可按时间 FIFO 删除;对「含 tool 的轮次」,必须整环删除或整环保留。

4. 摘要压缩:可替换中段,不可抹掉副作用

当中段仍超限、且业务允许丢失细粒度对话时,用一次压缩调用把中段变成结构化摘要,而不是递归无限压:

[SUMMARY OF EARLIER TURNS — machine generated]
- User goal: ...
- Decisions: ...
- Tools already executed (side effects):
  - send_email id=... status=ok  # 只记事实,不记长正文
  - write_file path=... sha256=...
- Open questions: ...
- Do NOT repeat completed side-effecting tools without new user intent.

实现约束:

建议
摘要写入位置 单独 role=system 或带标记的 user 块,插在 pinned 之后、recent 之前;不要每轮改写 system 最顶部前缀(利于 prompt cache)
原文保留 对象存储 / 会话表仍保留全量消息;摘要只是装配视图
触发 estimate >= summarize_trigger_ratio * limitmiddle_tokens >= 2 * summary_max_tokens
失败回退 摘要超时 / 失败 → 退回「只删环不摘要」;仍超限 → fail-closed
幂等 同一 message_range_hash 命中则复用已存摘要,避免每轮重算
温度 temperature=00.2;禁止工具调用(摘要模型挂载 tools 易递归)

Python 示意(装配入口):

@dataclass(frozen=True)
class AssemblyConfig:
    model_input_limit: int
    output_reserve_tokens: int = 8192
    safety_margin_tokens: int = 1024
    recent_tool_rings: int = 3
    summarize_trigger_ratio: float = 0.8
    summary_max_tokens: int = 1024

def input_token_budget(cfg: AssemblyConfig) -> int:
    return cfg.model_input_limit - cfg.output_reserve_tokens - cfg.safety_margin_tokens

def assemble_for_model(messages: list[dict], cfg: AssemblyConfig, count_tokens) -> list[dict]:
    protected_prefix, middle, recent = split_buckets(messages, cfg.recent_tool_rings)
    view = protected_prefix + middle + recent
    budget = input_token_budget(cfg)

    while count_tokens(view) > budget and middle:
        middle = drop_oldest_tool_ring(middle)
        view = protected_prefix + middle + recent
        assert not validate_tool_pairing(extract_edges(view))

    if count_tokens(view) > budget * cfg.summarize_trigger_ratio and middle:
        summary = summarize_or_none(middle, cfg.summary_max_tokens)
        if summary is not None:
            middle = [summary]
            view = protected_prefix + middle + recent

    if count_tokens(view) > budget:
        raise ContextAssemblyError("prompt exceeds budget after trim/summarize")
    return view

5. 与相邻控制面的衔接

控制面 衔接方式
单工具回灌预算 先截断 / 脱敏再入库;装配层假设单条 tool 已有上限,但仍要对历史条数负责
run max_turns / deadline 装配失败应计入 run 失败原因;不要在 deadline 后还启动摘要模型
并行 tool_calls 同一环内 N 条结果必须同进同出;并行扇出只影响执行,不影响删除粒度
异步 run 队列 / 检查点 检查点应存规范消息列表(含完整 tool 环),装配是 Worker 侧纯函数;崩溃恢复后用同一算法重放视图
Prompt cache 保持 system 前缀稳定;把易变摘要放在中段槽位;工具列表变更会使 cache 失效,需接受或做稳定排序的工具子集
可观测性 记录 prompt_tokens_before/afterdropped_ringssummary_usedassembly_strategy;低基数标签:modeltenantstrategy

建议指标:

  • context_assemble_tokens(histogram,装配后)
  • context_assemble_dropped_rings_total
  • context_assemble_summary_total / context_assemble_summary_fail_total
  • context_assemble_pairing_error_total
  • context_assemble_reject_total(fail-closed)
  • context_assemble_seconds

6. 配置示例(YAML)

context_assembly:
  model_input_limit: 128000          # 随模型替换
  output_reserve_tokens: 8192
  safety_margin_tokens: 1024
  recent_tool_rings: 3
  recent_user_turns: 2
  middle_delete_unit: tool_ring
  summarize_trigger_ratio: 0.8
  summary_max_tokens: 1024
  summary_timeout_ms: 8000
  on_pairing_error: fail_closed      # fail_closed | synthesize_error_results
  on_still_over_budget: fail_closed  # fail_closed | drop_tools_schema_then_fail
  token_counter: provider_or_tiktoken_model_encoding
  pin:
    - first_user_message
    - side_effect_ledger
    - security_system_prompt

风险与边界

Token 估计误差。 本地 BPE 与提供商实际计量常有偏差(特殊 token、图片、tool schema 包装)。必须保留 safety_margin_tokens,并在 4xx context_length_exceeded 时自动收紧一档(例如再丢 1 个 middle ring)后有限次重试(建议 ≤2),避免死循环重试烧钱。

摘要幻觉与副作用重复。 摘要模型可能漏记「已发送邮件」或编造未发生的步骤。副作用应以执行层账本(tool 名、幂等键、资源 id、状态)为准写入 pinned 区,摘要只作叙述辅助;写工具仍依赖幂等键 / HITL,不能靠摘要防重。

配对修复的滥用。 synthesize_error_results 会改变模型可见历史,可能诱导无意义重试。默认 fail-closed;仅对平台自身超时 / 取消产生的空洞启用合成,并打度量告警。

多模态与非文本块。 图像、文件附件、MCP resource 的 token 成本远高于纯文本字符比。装配层必须把它们计入 count_tokens;超限时优先降清晰度 / 去附件,而不是只砍文本 middle。

厂商语义差异。 同一套「环」在 Chat Completions、Responses API、Anthropic Messages、各类兼容网关上的消息形状不同。适配层应按 provider 做往返单测:单 tool、并行双 tool、tool 后 user 追问、中段删环、摘要替换、破损配对拒绝。

隐私与租户隔离。 摘要调用若走独立提供商,等于把历史再出境一次;需与主路径相同的保留策略、密钥脱敏与租户密钥隔离。日志默认只记 hash 与 token 数,不记全文。

与「记忆系统」的边界。 长期记忆(向量库、用户偏好)是检索注入,不是历史删减的替代品。注入内容仍占 pinned/system 预算,且可能引入间接提示注入;装配层应标记来源并限制条数。

删历史 ≠ 回滚世界。 从 prompt 中去掉某次 DROP TABLE 的 tool 结果,并不会恢复数据库。上下文装配只影响模型下一步条件,不提供事务语义。

参考来源

同系列交叉引用

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com