这篇不是讲"提示词怎么写得更优雅"。我只看一个更硬的问题:Agent 跑久以后,上下文到底是怎么胖起来的,哪一刀最值得先砍。实验脚本和结果都放在本地目录里,可以复跑。
你大概见过这种故障:
Agent 前 10 分钟很听话,工具也能正常调。跑到后面,开始变慢、重复解释、忘记刚才的发布规则,甚至把已经验证过的 URL 又验证一遍。
很多人第一反应是"模型不稳"。
我现在更倾向于先怀疑上下文管线。模型只是消费上下文的人,真正把它喂撑的,通常是这三类东西:
- 全量历史每轮重发
- 工具结果原样塞回对话
- 工具 schema 和说明越写越长
我用一个很小的 Python 脚本跑了 3 个实验。不是线上 API benchmark,也不拿它证明某个模型强弱;它只回答一个工程问题:在 Agent 系统里,哪些上下文最容易失控。
实验目录:experiments/run_experiments.py。
什么是 OpenClaw 上下文工程
OpenClaw 上下文工程,指的是在多工具、多轮、多 agent 的运行过程中,控制"发给模型的输入材料":历史消息、工具 schema、工具返回值、规则锚点、摘要状态和验证证据。
它不是单纯缩短 prompt。更准确地说,它是一套预算系统:哪些内容必须每轮出现,哪些内容只保留摘要,哪些内容应该写入文件或 receipt,而不是继续塞进下一轮对话。
实验环境:只测 payload,不测模型能力
为了避免把话说大,我把实验限定得很窄:
- Python 3 标准库
json.dumps(..., ensure_ascii=False)序列化- UTF-8 bytes 作为 payload 大小指标
- 固定随机种子
519 - 合成 40 轮 Agent 对话,每轮包含用户请求、工具结果、助手回复
这里没有调用线上模型,所以我不会写"某模型实测提升多少"。我测的是输入材料本身的体积变化。
完整实验骨架如下:
python
import json, random, string
random.seed(519)
def cjk(n: int) -> str:
words = "上下文 工具调用 文件搜索 发布脚本 运行日志 失败重试 摘要 压缩 预算 规则 锚点 验证 响应 状态 证据 结构化 输出".split()
s = []
while len("".join(s)) < n:
s.append(random.choice(words))
s.append(",")
return "".join(s)[:n]
def make_turn(i: int):
tool = {
"tool": "search_files",
"args": {"pattern": "context|token|publish", "path": f"/repo/module_{i % 7}"},
"matches": [
{
"path": f"src/module_{i % 7}/file_{j}.ts",
"line": 20 + j,
"content": cjk(90),
"score": round(random.random(), 4),
"mtime": "2026-05-19T09:00:00+08:00",
"debug": {"rank": j, "trace": "x" * 48},
}
for j in range(10)
],
}
return [
{"role": "user", "content": f"第 {i} 轮:请继续定位上下文膨胀问题。"},
{"role": "tool", "name": "search_files", "content": json.dumps(tool, ensure_ascii=False)},
{"role": "assistant", "content": cjk(420)},
]
这个构造不复杂,但很接近真实 Agent 的痛点:文件搜索、日志检索、发布脚本输出,都很容易返回一大坨结构化文本。
实验 1:全量历史不是慢慢变贵,是迟早把你拖死
我比较了三种历史策略:
| 轮数 | 全量历史 bytes | 最近 6 轮 bytes | 摘要 + 最近 6 轮 bytes |
|---|---|---|---|
| 1 | 6,829 | 6,984 | 7,197 |
| 5 | 34,155 | 34,310 | 34,523 |
| 10 | 68,313 | 41,145 | 41,358 |
| 20 | 136,632 | 41,146 | 41,359 |
| 40 | 273,269 | 41,145 | 41,358 |
这里有个反直觉点:前 5 轮,全量历史看起来没什么问题,甚至比"摘要 + 窗口"更小一点。因为摘要和规则锚点本身也占空间。
但从第 10 轮开始,差距就拉开了。
到第 40 轮,全量重放已经到 273,269 bytes ;最近 6 轮策略稳定在 41,145 bytes 左右。也就是说,在这个合成场景里,滑动窗口把历史 payload 压到了全量的约 15.1%。
这就是很多 Agent 故障"不在开头暴露"的原因。
系统刚启动时,你怎么写都行。历史还短,工具结果还少,规则还在上下文前面。等它跑到第 20 轮、第 40 轮,规则被挤远、工具日志开始堆积、模型要在一堆旧证据里找当前任务。它不是突然变笨,是你给它的输入变脏了。
最小修复代码很短:
python
CRITICAL = """关键规则:
1) 缺证据不下结论。
2) 工具结果先摘要再回灌。
3) 发布后必须打开公开页验证。
"""
def build_messages(turns, keep=6, summary=""):
recent = turns[-keep * 3:]
messages = [{"role": "system", "content": CRITICAL}]
if summary:
messages.append({"role": "system", "content": "会话摘要:" + summary})
messages.extend(recent)
return messages
我不建议一上来就做复杂记忆系统。先做 3 件事就够了:
- 关键规则每轮重复,短一点
- 最近 N 轮完整保留
- 更早的内容只保留摘要和 receipt 路径
在 OpenClaw 这种会跨渠道、跨 agent、跨 cron 跑的系统里,receipt 路径很重要。比如发布结果、截图、日志,不要全塞进对话;写入文件,再把文件路径和 2 行摘要放回上下文。
实验 2:工具结果原样回灌,是最容易砍的一刀
第二个实验更直接:构造 80 条 search match,每条带 path、line、content、before、after、score、mtime、sha、debug trace。然后做一个紧凑版,只保留:
- 总匹配数
- top 8
- path / line / score
- 45 字 snippet
结果:
| 工具结果版本 | bytes | 相对原始体积 |
|---|---|---|
| 原始 JSON | 98,387 | 100% |
| 摘要 JSON | 2,043 | 2.1% |
| 减少比例 | - | 97.9% |
这个结果比我预期更夸张。
原因不是 JSON 本身"坏",而是工具结果里混了太多对下一步决策没用的字段。mtime、sha、debug.trace、完整上下文行,在定位工具自身 bug 时有价值;但在让 Agent 决定"下一步读哪个文件"时,它们大多是噪声。
我现在更喜欢把工具分成两层输出:
python
def compact_search_result(raw: dict, top_k: int = 8) -> dict:
matches = raw.get("matches", [])
matches = sorted(matches, key=lambda x: x.get("score", 0), reverse=True)
return {
"query": raw.get("query"),
"match_count": len(matches),
"top_matches": [
{
"path": m["path"],
"line": m["line"],
"score": round(m.get("score", 0), 3),
"snippet": m.get("content", "")[:80],
}
for m in matches[:top_k]
],
}
如果下一步真的需要完整证据,再让 Agent 读文件或打开日志。不要因为"可能有用",就把所有字段每轮都带着跑。
这里有个实际经验:很多失败不是"缺信息",而是"信息太多但没有优先级"。模型看到 80 条 match,每条都有很多字段,它会尝试做阅读理解;你给它 top 8 + score + snippet,它更像是在做决策。
实验 3:schema 也会胖,而且胖得很安静
第三个实验测工具 schema。每个工具包含 name、description、6 个参数说明。工具数从 6 增加到 100。
| 工具数量 | schema bytes | Python JSON parse p50 | p95 |
|---|---|---|---|
| 6 | 11,771 | 0.015 ms | 0.031 ms |
| 12 | 23,533 | 0.029 ms | 0.043 ms |
| 24 | 47,065 | 0.053 ms | 0.064 ms |
| 60 | 117,661 | 0.131 ms | 0.169 ms |
| 100 | 196,101 | 0.227 ms | 0.269 ms |
别误会,parse 时间不是重点。Python 本地解析这点 JSON 很快。
真正的问题是:这些 schema 最终会变成模型输入。100 个工具的说明,在这个构造里已经接近 196KB。如果每轮都带上,再叠加历史和工具结果,你的上下文预算会被"静态说明书"吃掉一大块。
这也是为什么我不喜欢给一个 agent 挂 50 个"可能会用到"的工具。工具越多,不只是选择更难,输入也更脏。
更稳的做法是按任务装载工具:
python
TOOLSETS = {
"publish": ["read_file", "write_file", "browser_publish", "screenshot"],
"debug": ["read_file", "search_files", "run_command", "process_log"],
"research": ["web_search", "web_fetch", "note_save"],
}
def select_tools(intent: str) -> list[str]:
if "发布" in intent or "CSDN" in intent or "掘金" in intent:
return TOOLSETS["publish"]
if "报错" in intent or "日志" in intent or "定位" in intent:
return TOOLSETS["debug"]
return TOOLSETS["research"]
这段代码很粗糙,但方向对:先缩工具面,再让模型选工具。
我更推荐把"工具选择"前置成路由问题,而不是把所有工具摊开让模型自己挑。尤其是 OpenClaw 这种多 agent 场景,writing-studio、coder、brain、publisher 本来就应该有不同工具边界。
一个可落地的上下文预算表
我会把 Agent 输入拆成 5 个桶,每个桶给一个粗预算。这里的数字不是行业标准,是这次实验后我自己的默认值:
| 输入桶 | 建议预算 | 超预算时怎么处理 |
|---|---|---|
| 关键规则 | 1-2KB | 只留不可破坏的规则,删解释 |
| 当前任务 | 2-6KB | 让用户意图保持原文,不要摘要过度 |
| 最近历史 | 30-50KB | 滑动窗口,保留最近 4-8 轮 |
| 工具结果 | 单次 2-8KB | top_k + snippet + 证据路径 |
| 工具 schema | 10-60KB | 按任务装载,不全量挂载 |
如果你只能改一处,我建议先改"工具结果"。
原因很简单:它收益最大、风险最小。历史摘要可能会丢细节,工具裁剪则可以保留原始文件路径,需要时再读。你不是删除证据,只是不把证据全文塞进下一轮。
我会怎么改一个真实 Agent
拿一个发布型 agent 举例。它需要写文章、生成封面、发 CSDN、发掘金、验证公开页。最容易出问题的地方不是写作,而是发布后状态回灌。
错误做法:
python
# 不要这样:把整段浏览器日志、HTML、Network 输出全部塞回对话
messages.append({
"role": "tool",
"content": full_browser_log + full_html + full_network_trace,
})
更好的做法:
python
from pathlib import Path
import json
def save_receipt(platform: str, payload: dict, run_dir: str) -> dict:
path = Path(run_dir) / "receipts" / f"{platform}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return {
"platform": platform,
"status": payload.get("status"),
"url": payload.get("url"),
"receipt_path": str(path),
"evidence": payload.get("evidence", [])[:3],
}
对 Agent 来说,下一轮只需要知道:成功没、URL 是什么、证据在哪。如果它要复盘,再读 receipt 文件。
这个模式还有一个好处:人也更容易审计。你不用在一大段对话里找"到底发没发成功",直接看 receipts。
非显而易见的发现:摘要不是越早越好
实验 1 里,第 1 轮和第 5 轮,"摘要 + 最近 6 轮"比全量历史略大。
这说明一个小坑:别把摘要当成银弹。对短会话,摘要本身也是负担。真正该做的是阈值触发:
- 低于 6 轮:不摘要
- 超过 6 轮:滑动窗口
- 出现跨阶段任务:生成阶段摘要
- 发布、部署、删除这类动作:写 receipt,不靠记忆
伪代码如下:
python
def should_summarize(turn_count: int, stage_changed: bool, payload_bytes: int) -> bool:
if stage_changed:
return True
if turn_count <= 6:
return False
return payload_bytes > 50_000
这个判断比"每 5 轮自动摘要一次"更实用。因为真正的切点不是轮数,而是阶段和 payload。
OpenClaw 场景里的 4 条规则
我现在会给 OpenClaw agent 加这 4 条默认规则:
- 工具结果默认摘要,完整原文写文件
- 每个 agent 只挂当前任务需要的工具集
- 发布、部署、外部副作用必须写 receipt
- 最终状态必须二次验证,不能只信脚本返回
第 4 条尤其重要。发布脚本返回 success,只能说明"提交动作完成";公开页是否可见、是否审核通过,还要打开 URL 看一次。
这不是洁癖,是为了防 phantom publish。自动化最危险的不是失败,而是假成功。
常见问题
Q: 为什么不用 token 计数,而用 bytes? A: 这次实验只想测 payload 体积变化,不比较模型。bytes 可以稳定复现,也不依赖具体 tokenizer。真实上线时,应该把 bytes 和模型 tokenizer 计数都打进日志。
Q: 工具结果摘要会不会漏掉关键证据? A: 会,所以不要只保留摘要。正确做法是"摘要进上下文,原文进文件"。Agent 需要细节时再按路径读取。这样既保留证据,又不污染每一轮输入。
Q: OpenClaw Agent 最先该优化哪里? A: 先看 receipts 和工具日志。如果一次工具调用返回超过 20KB,却只用其中 3 行做决策,就先做 compaction。比改 prompt 更快见效。
结论:别先调模型,先管输入
这 3 个小实验给我的结论很朴素:Agent 变慢、变飘、重复工作,很多时候不是"智商问题",而是输入治理问题。
全量历史会线性膨胀;工具结果原样回灌最浪费;schema 数量太多会安静吃掉预算。
我自己后面会把这套规则直接放进 OpenClaw 写作和发布 agent:工具结果先压缩,关键动作写 receipt,公开页再验证。等这些基础管住了,再讨论换模型、换路由、换工作流,才有意义。