Agent 返回太长怎么办?3 种结果截断策略实测对比
先给结论:我用一份 200 条记录、28,418 字符的模拟订单查询结果做实测,在同样 2000 字符的预算下——按字符硬截只保住了 1/10 个预埋关键事实,而且整段 JSON 直接变成不可解析;按前 20 条记录截断保住了 2/10,字符数却膨胀到 2,951,超出预算 47.5%;结构化保留(异常行 + 头尾样本 + 预算内逐条装入) 在 1,884 字符里保住了 6/10,JSON 仍然可解析,耗时 0.36ms。截断策略的价值不在“切得短”,而在“切完之后还能用”;而且客户端截断永远只能兜底,真正的解法是在工具侧先把结果收窄。
为什么 Agent 的结果总是太长
Agent 调用一个查询类工具,拿回来的往往不是一条答案,而是一整页原始数据:订单列表、搜索结果、日志片段、网页正文。这些内容有几个共同点:
- 条数多:一次返回几百条是常态,每条又带十几个字段;
- 长度不可控:同样的查询,今天返回 30 条,明天可能返回 800 条;
- 结构有价值:字段名、类型、嵌套关系本身就是信息,随手切一刀就废了。
而 Agent 的上下文预算是有限的。于是很多人写下第一版兜底逻辑:text[:2000]。这行代码能跑,但它会同时切掉三样东西——后半段的数据、JSON 的闭合括号、以及“这条结果被截断了”这个事实本身。下一轮 Agent 拿到一段残缺文本,要么解析报错,要么基于半截数据给出错误结论。
三种策略的最小实现
实验里对比的是三种最常见写法。
# 1) 按字符硬截:最简单,也最容易把结构切坏
def head_chars(text, budget=2000):
return text[:budget]
# 2) 按条数截断:保留 JSON 结构,但只覆盖开头
def head_rows(text, n=20):
data = json.loads(text)
data["items"] = data["items"][:n]
data["truncated"] = True
return json.dumps(data, ensure_ascii=False)
# 3) 结构化保留:异常行优先,头尾兜底,按预算逐条装入
def head_tail_scan(text, head_n=5, tail_n=3, budget=2000):
data = json.loads(text)
rows = data["items"]
picked, seen = [], set()
for group in ([r for r in rows if r["error_code"]],
rows[:head_n], rows[-tail_n:]):
for r in group:
if r["id"] not in seen:
seen.add(r["id"])
picked.append(r)
kept = []
for r in picked:
trial = sorted(kept + [r], key=lambda x: x["id"])
data["items"] = trial
data["truncated"] = True
data["kept_rows"] = len(trial)
if len(json.dumps(data, ensure_ascii=False)) <= budget:
kept = trial
data["items"] = kept
data["kept_rows"] = len(kept)
return json.dumps(data, ensure_ascii=False)
第三种写法有两个关键点:异常记录优先,因为它们最可能影响 Agent 的判断;每次加入后重新序列化并检查长度,而不是先拼好再切字符串,这样输出永远是合法 JSON。
实测数据
样本用固定随机种子生成 200 条订单记录,每条包含 id、status、amount、buyer、sku、qty、error_code、note 八个字段。其中 10 条记录埋了唯一标记 KEY-01 到 KEY-10,分布在头部、尾部、异常行和普通中间行四个区域。三种策略各跑 30 次取耗时中位数,结果如下:
| 策略 | 输出字符 | 占原始比例 | JSON 可解析 | 关键事实保留 | 耗时中位数 |
|---|---|---|---|---|---|
| 字符硬截 2000 | 2,000 | 7.0% | 否 | 1/10 | 0.00ms |
| 前 20 条记录 | 2,951 | 10.4% | 是 | 2/10 | 0.22ms |
| 结构化保留 | 1,884 | 6.6% | 是 | 6/10 | 0.36ms |
三项差异值得逐条看:
第一,字符硬截的 1/10 不是偶然。 2,000 字符大约只能覆盖前 17 条记录,而 10 个关键事实里有 8 个在后面,唯一保住的 KEY-01 刚好落在第 2 条。同时输出以 {"tool":"order.query"... 开头,却在中途断掉,json.loads 必然失败。这意味着下游要么自己写容错解析,要么直接报错重试,等于把一次截断变成了两次失败。
第二,按条数截断不等于按预算控制。 只取前 20 条,输出仍有 2,951 字符,比 2000 的预算高出 47.5%。原因很直接:每条记录长度并不相等,note 字段短的二十几个字,长的四十几个字,固定条数无法对应固定字符数。而且它只覆盖开头 20 条,KEY-03 到 KEY-10 全部丢失,保留率只有 2/10。
第三,结构化保留把保留率拉到 6/10,代价是 0.36ms。 它先放异常行(KEY-02、KEY-04、KEY-06、KEY-09),再放头部 5 条(含 KEY-01)和尾部 3 条(含 KEY-10),并在 2000 字符预算内逐条装入,最终用 1,884 字符装下了 12 条记录。JSON 始终合法,下游可以直接解析。
但也要看清它的边界:KEY-03、KEY-05、KEY-07、KEY-08 这 4 个关键事实,任何客户端截断都救不回来。 它们藏在普通中间行里,既没有异常码,也不在头尾。这不是策略不够聪明,而是“先拿全量再截断”这条路本身的信息损失上限。
怎么选
按使用场景分三档:
- 能改工具参数的场景,优先在工具侧收窄:字段只取需要的几个、加条件过滤、强制分页。客户端截断只做最后一道保险。实验里那 4 个丢失的关键事实,靠一次
status=refunding或error_code!=null的查询条件就能提前捞出来,成本远低于在上下文里塞全量数据。 - 只能拿到文本的场景,用结构化保留替代字符硬截:异常行优先、头尾兜底、逐条装入并校验长度。它的实现只比一行切片多十几行,但保留率和可解析性完全不是一个量级。
- 确定只需要开头内容的场景,按条数截断仍然够用,但要把条数换算成字符上限再校验一次,别让 20 条悄悄变成 3,000 字符。
一句话总结:先问“这条结果能不能少拿”,再问“拿到之后怎么切”。 顺序反了,后面所有优化都是在补前面的坑。
复现
脚本在 experiments/truncate_experiment.py,只依赖 Python 标准库,不需要任何外部模型或网络。运行:
cd experiments
python -X utf8 truncate_experiment.py
固定随机种子 20261111,结果输出为 JSON,同时保存在 truncate_result.json。换种子会改变具体数值,但三种策略的相对差距稳定复现。环境为本机 Python 3.15。
下一步
这次实验只把“异常行”定义为 error_code 非空。实际业务里更值得优先保留的可能是状态为退款中的记录、金额超过阈值的记录,或者命中正则的可疑备注。下一步准备把优先级判定改成可配置的规则表,再测一轮:规则越精细,同样预算下能保住的关键事实是否还能再往上走。另一个方向是两次截断——先让工具侧返回聚合摘要,再对摘要做结构化保留,把客户端这一层的压力继续往下压。
更多推荐

所有评论(0)