先给结论:我用一份 200 条记录、28,418 字符的模拟订单查询结果做实测,在同样 2000 字符的预算下——按字符硬截只保住了 1/10 个预埋关键事实,而且整段 JSON 直接变成不可解析;按前 20 条记录截断保住了 2/10,字符数却膨胀到 2,951,超出预算 47.5%;结构化保留(异常行 + 头尾样本 + 预算内逐条装入) 在 1,884 字符里保住了 6/10,JSON 仍然可解析,耗时 0.36ms。截断策略的价值不在“切得短”,而在“切完之后还能用”;而且客户端截断永远只能兜底,真正的解法是在工具侧先把结果收窄。

为什么 Agent 的结果总是太长

Agent 调用一个查询类工具,拿回来的往往不是一条答案,而是一整页原始数据:订单列表、搜索结果、日志片段、网页正文。这些内容有几个共同点:

  1. 条数多:一次返回几百条是常态,每条又带十几个字段;
  2. 长度不可控:同样的查询,今天返回 30 条,明天可能返回 800 条;
  3. 结构有价值:字段名、类型、嵌套关系本身就是信息,随手切一刀就废了。

而 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 可解析关键事实保留耗时中位数
字符硬截 20002,0007.0%否1/100.00ms
前 20 条记录2,95110.4%是2/100.22ms
结构化保留1,8846.6%是6/100.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 非空。实际业务里更值得优先保留的可能是状态为退款中的记录、金额超过阈值的记录,或者命中正则的可疑备注。下一步准备把优先级判定改成可配置的规则表,再测一轮:规则越精细,同样预算下能保住的关键事实是否还能再往上走。另一个方向是两次截断——先让工具侧返回聚合摘要,再对摘要做结构化保留,把客户端这一层的压力继续往下压。

Logo

更多推荐