生产 Agent 的一次 run 往往包含多轮「模型 → tool_calls → 回灌 → 再模型」。单次工具结果的字节 /token 预算能压住一步回灌;run 级 max_turns / 墙钟 deadline 能挡住无限环;但二者都不解决另一类故障:合法历史消息随轮次线性膨胀,下一请求直接撞上下文窗口上限,或在裁剪时拆坏 tool 配对导致提供商 400。本文只讨论 把持久化会话装配成下一次模型请求 messages / input 的策略与参数,不重复单工具回灌截断、流式 arguments 拼装、以及 run 预算 / 取消传播的细节(见近期专文)。
问题背景:上下文是有结构的字节预算,不是「字符串越短越好」
典型数据流:
- 会话存储持有完整消息序列:
system/developer、用户输入、assistant(含tool_calls/tool_use)、tool 结果、可选中间摘要。 - 每一轮模型调用前,编排器从存储装配一份 prompt 视图:可能裁剪、摘要、重排(通常不重排角色顺序)、注入最新策略与工具清单。
- 装配结果经 tokenizer 或官方计数 API 校验后发给提供商;失败则降级策略或中止 run。
在缺少显式装配层时,常见故障包括:
- 历史无界:每轮 tool 结果即使各自 ≤ 8k tokens,20 轮后仅 tool 侧即可超过 100k;再加 system、技能文档与长用户粘贴,费用与延迟线性上升,最终触发
context_length_exceeded类错误。 - 配对被拆坏:OpenAI 风格下,带
tool_calls的 assistant 消息之后,必须有对应role=tool且tool_call_id匹配的结果;Anthropic 风格下,tool_use与tool_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 content 中 tool_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
修复策略(按安全优先级):
- fail-closed(默认):配对破损则中止本轮模型调用,记录
context_assembly_pairing_error,向用户 / 运维暴露可重试的内部错误,而不是「猜着删」。 - 协议修复:仅当破损来自本平台已知 bug(例如并行 tool 中一路超时未写结果)时,为缺失 id 合成
isError=true的占位 tool 结果(文案固定、不含内部栈),再装配。 - 禁止:自动删除 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 |
4096~16384 |
防止「输入塞满导致输出只剩几百 token」 |
safety_margin_tokens |
512~2048 |
tokenizer 估计误差与提供商额外包装 |
recent_tool_rings |
2~4 |
后缀至少保留的完整「assistant tool_calls + 全部 results」环数 |
recent_user_turns |
1~3 |
后缀额外保留的最近用户消息数(不含已计入 ring 的) |
middle_delete_unit |
tool_ring |
中段删除粒度:整环,而非单条消息 |
summarize_trigger_ratio |
0.75~0.85 |
估计 prompt 占 model_input_limit 比例超过后触发摘要或删块 |
summary_max_tokens |
512~2048 |
单次压缩摘要上限 |
summary_model |
小模型或同模型低温 | 与主模型分离时可降本;需独立超时与失败回退 |
token_counter |
与目标模型一致的 tokenizer 或官方计数 API | 禁止用 len(text)/4 作为唯一依据上线 |
3. 分层裁剪:先整环删除中段,再考虑摘要
优先级从高到低保留:
- 安全与策略类 system /developer(含「不可覆盖」条款)。
- 当前 run 的 pinned 用户目标与「已执行副作用」清单(若有)。
- 最近
recent_tool_rings个完整 tool 环。 - 中段历史中带
isError的短工具结果(有利于自纠;可设条数上限)。 - 更早的纯文本 user/assistant 闲聊。
- 中段成功且冗长的 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 * limit 且 middle_tokens >= 2 * summary_max_tokens |
| 失败回退 | 摘要超时 / 失败 → 退回「只删环不摘要」;仍超限 → fail-closed |
| 幂等 | 同一 message_range_hash 命中则复用已存摘要,避免每轮重算 |
| 温度 | temperature=0~0.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/after、dropped_rings、summary_used、assembly_strategy;低基数标签:model、tenant、strategy |
建议指标:
context_assemble_tokens(histogram,装配后)context_assemble_dropped_rings_totalcontext_assemble_summary_total/context_assemble_summary_fail_totalcontext_assemble_pairing_error_totalcontext_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 结果,并不会恢复数据库。上下文装配只影响模型下一步条件,不提供事务语义。
参考来源
- OpenAI — Function calling
- OpenAI API Reference — Chat Completions(
messages、tool_calls、role: tool) - OpenAI — Responses API
- Anthropic — Tool use with Claude
- Anthropic — Handle tool calls(
tool_use/tool_result配对) - Anthropic — Prompt caching(前缀稳定与 cache 边界)
- OpenAI — Prompt caching
- Model Context Protocol — Tools
- OWASP Gen AI Security Project — LLM01 Prompt Injection / LLM06 Excessive Agency
- tiktoken(OpenAI 开源 BPE tokenizer,用于本地估计)
同系列交叉引用
- Agent 工具结果回灌:字节 /token 预算、头尾截断与密钥脱敏流水线
- Agent 多轮循环运行预算:max_turns、墙钟 deadline、token / 费用上限与取消传播
- Agent 流式 tool_calls 增量拼装:index 对齐、arguments 缓冲与完成门闩
- Agent 异步 run 队列:PostgreSQL SKIP LOCKED 租约、心跳续租与至少一次语义
- Agent 并行 tool_calls:信号量、提供商 fan-out 上限与部分失败回灌
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。