42 万字符的工具输出压进 6,945 token:Agent 长会话的上下文治理实践

本文是一次个人技术复盘,公开版本全部脱敏;文中案例数字只作为单场景历史记录引用,不推为通用结论。配套最小示例独立编写,不依赖任何内部仓库。

副标题:不把完整大数组喂给模型------截断、旁路缓存与参数注入的工程取舍

引子:Agent 调工具,第一个踩坑是"什么都想喂给模型"

LLM Agent 最容易被忽视的成本,不是模型调用本身,而是工具返回什么就往 prompt 里塞什么。

工具返回的常常是数组、JSON、表格、日志、格点数据;而模型做决策真正需要的,往往只是统计量、极值、是否命中、有没有越界。把完整结构直接拼进上下文,会同时带来三个问题:

  • 窗口被挤占:长明细占据绝大部分上下文预算,留给任务指令、历史摘要和后续工具结果的空间所剩无几;

  • 有效指令被淹没:关键判断条件混在大段明细里,模型需要"在噪声里找规则",注意力被分散;

  • 成本与延迟失控:每次调用都把超长明细重新过一遍 token 预算,对话越长,浪费越累积。

本文复盘一个真实遇到过的场景:一个行业工具返回了一份体量很大的结构化结果,我们在不丢弃完整数据的前提下,把模型可见的上下文压到了决策需要的程度。下面先讲问题复盘,再拆三个治理手段、几处取舍和容易踩的坑。

问题复盘:大数组进 prompt 之后发生了什么

现象

工具返回一份长列表 / 格点结构的结果。模型上下文被这份主体占据,后续指令与新的工具结果在窗口中互相挤压:要么早期内容被挤出窗口,要么关键判断条件排在明细之后、模型难以聚焦。

体量记录(单场景历史记录)

这是某一个历史场景留下的记录值,先把口径讲清楚:

  • 该工具结果约 419,682 字符 ,对应 prompt token 记录为 917,403;

  • 做上下文治理之后,该场景 prompt token 记录为 6,945。

边界声明(单独说明,避免误读)

  1. 这是某一个历史场景的记录值,不是批量压测或通用统计;

  2. 同一份历史记录明确:在截断这个阶段之后,该场景的后续工具并没有继续调用 ;因此这组 token 差异只证明上下文体量下降,不代表工具链被端到端续接成功,也不是"模型自主恢复了完整链路";

  3. 它不是通用成本改善率,不能据此推算"整体上下文成本下降多少";

  4. 本文不提供准确率、召回率、响应延迟等评测数字------这些在当时没有完整评测集支撑,本文只讲机制与取舍。

把口径锁死之后,再看工程上具体做了什么。

治理手段一:工具返回主体截断,模型只看决策需要的信息

截断不是粗暴砍半,而是按工具类型决定保留什么、截掉什么:

  • 截掉:大数组主体、重复字段、逐条明细记录;

  • 保留:头部摘要、关键统计量(总数、均值、极值、阈值越界条数)、命中标记、来源标识;

  • 不直接丢弃:截断后必须在模型可见内容里留一个"完整副本在哪里"的引用,而不是把明细删掉就结束。

最小示例:模型可见部分 = 统计量 + 头部 N 条 + 缓存引用键

python 复制代码
def truncate_for_model(records, cache_ref, head_n=20):
    """把工具返回的大记录集压成模型可见的紧凑视图。"""
    total = len(records)
    if total == 0:
        return {"summary": "no records", "cache_ref": cache_ref}

    summary = {
        "total": total,
        "max_value": max(r["value"] for r in records),
        "min_value": min(r["value"] for r in records),
        "out_of_range": sum(1 for r in records if r["value"] < 0),
    }
    # 只保留头部少量明细作为抽样样本,完整列表走旁路缓存
    return {
        "summary": summary,
        "head_sample": records[:head_n],
        "cache_ref": cache_ref,  # 完整数据在旁路缓存中的引用键
    }

模型拿到的是一份"够用的摘要 + 极少量样本 + 取回完整数据的钥匙",而不是上万条明细。

治理手段二:完整数据旁路缓存,下游脚本按需取回

设计思路

完整工具输出写入一份独立缓存 ,按工具调用实例隔离 key;模型上下文里只放截断后的主体和续接提示。下游脚本确实需要明细时,通过参数占位(形如 __cachedData 的引用键)从旁路缓存把完整数据取回,而不是让模型上下文背着完整数组走。

两段缓存必须隔离

工程里通常存在两类用途不同的缓存:

  • 工具链完整输出缓存:存某一次工具调用的完整返回,供后续脚本取回;

  • 查询结果缓存:存某类查询的检索 / 计算结果,供同类请求复用。

两者必须分开 key 空间、分开读写。混放在一起会互相覆盖、串实例数据:工具链缓存被查询结果覆盖,或者上一次调用的残留数据被这次取回。

最小示例:旁路缓存读写与隔离 key

python 复制代码
class SideCache:
    def __init__(self):
        self.tool_chain = {}   # 工具链完整输出缓存
        self.query_result = {} # 查询结果缓存,两类隔离

    def save_tool_output(self, instance_id, payload):
        # 按工具调用实例隔离,引用键绑定到本次实例
        self.tool_chain[f"tool:{instance_id}"] = payload

    def save_query_result(self, query_key, payload):
        self.query_result[f"query:{query_key}"] = payload

    def get_tool_output(self, ref):
        return self.tool_chain.get(ref)

一致性与生命周期

  • 旁路缓存的生命周期与工具调用实例绑定:会话结束或显式清理时一并回收;

  • 它是临时中转,不是长期知识库,不要把旁路缓存当成持久化存储去依赖;

  • 引用键只能取回当前实例允许访问的数据,不接受模型伪造出的跨实例键。

最小示例:下游脚本通过引用键取回完整数据

python 复制代码
def downstream_script(parameter_map, side_cache):
    # 模型上下文里只带了 __cachedData 引用键,脚本据此取回完整明细
    ref = parameter_map.get("__cachedData")
    full_records = side_cache.get_tool_output(ref)
    if full_records is None:
        return {"status": "expired", "message": "缓存已过期,请重新调用工具"}
    # 在脚本内部基于完整数据做逐条处理,模型上下文不参与明细级计算
    return {"status": "ok", "processed": len(full_records)}

治理手段三:长会话配套------Token 预算、历史摘要、上下文截断

截断解决的是"单次工具返回太胖",长会话还需要三条配套机制配合,本文只讲机制与取舍,不写死具体轮数、阈值等内部配置值。

Token 预算控制:先估算当前上下文的体量,超过阈值再触发摘要或截断,而不是每次都全量拼接历史;预算判断发生在组装 prompt 之前。

历史摘要:早期对话压缩为摘要,保留任务目标、已确认结论和待办;细节结论保留、对话过程不保留。

上下文截断:保留系统提示、最近若干轮与当前任务所需片段,更早的轮次移出窗口;被移出的内容如果仍关键,应进入摘要或旁路缓存,而不是期望它一直留在窗口里。

这三者配合的顺序通常是:先做单次工具返回截断(手段一、二),再对历史做摘要与预算控制(手段三),避免把精力花在压缩一段本来就不该进上下文的明细上。

取舍与坑(这部分在面试里最值得展开)

截断会丢掉什么

模型看不到明细,就无法完成"需要逐条判断"的决策。这类任务的正确做法是在工具层先聚合 / 先过滤 ,而不是截断之后再硬塞给模型做逐条比对。如果业务确实需要模型逐条看原文条款,那属于结构化检索要解决的问题(配套公开工程:hybrid-rag-spring-ai,向量 + 全文两路召回、RRF 融合),不应该把全文塞进上下文。

旁路缓存的安全边界

  • 参数注入只能取回当前实例允许的数据;

  • 引用键不可被模型伪造、不可跨实例引用;

  • 取回动作要在受控脚本侧完成,不把完整明细再回灌进模型上下文。

什么时候不该截断

需要模型逐条比对、引用原文条款、对照多条记录找差异的场景,应改走检索与结构化处理;强行截断会让模型在信息不全的情况下做判断,错误比"窗口紧张"更难发现。

可观测性

记录每次触发截断 / 摘要时的上下文体量、触发的工具类型、缓存引用是否被后续脚本取回。复盘时先回答"哪几类工具最容易膨胀、哪类取回最频繁",再决定阈值和默认截断策略,而不是凭感觉调参。

可复现最小示例

本文三段最小示例已经覆盖完整闭环:工具返回大列表 → 主体截断 + 旁路缓存 → 下游脚本按引用键取回完整数据。示例独立于任何内部项目,可直接在本地 Python 环境跑通。

配套检索工程:hybrid-rag-spring-ai(向量 + 全文两路召回、RRF 融合、可选重排,Spring Boot 3 + JDK 17 教学样例)。检索工程解决"找得到相关片段",本文解决"模型上下文只留决策所需",两者互为补充。

结论

上下文治理的核心不是"压缩得越小越好",而是一条清晰的分工:

模型上下文只保留决策所需信息;完整数据走旁路、按需取回;历史过程用摘要沉淀,而不是长期堆在窗口里。

最后重申边界:文中 917,403 → 6,945 是单场景历史记录,不代表工具链恢复成功,也不是通用降本比例;真实效果必须用自己的评测集、自己的工具类型去度量。机制可以复用,数字不能照搬。


作者 :殷晗 · Java 后端 / AI 应用工程 配套工程 :hybrid-rag-spring-ai(向量 + 全文两路召回、RRF 融合的教学样例)

相关推荐
weixin_448771721 小时前
Spring框架之ConfigurableApplicationContext源码学习
java·spring
Data-Miner1 小时前
一个省大模型积分的 Excel 数据处理方案:脱敏生成逻辑,脚本复用把 token 降到 0
人工智能·windows·excel
Dawson Zhu1 小时前
【CUDA编程入门⑧】性能分析工具链:Nsight、Profiler与Roofline模型
人工智能·语言模型·架构·aigc·agi
无巧不成书02181 小时前
Tomcat部署JSP文件完整指南:手动部署+IDEA工程化实现 附404访问错误全排查
java·ide·windows·tomcat·intellij-idea·js
Smoothcloud润云1 小时前
GPU远程纳管节点频繁离线?心跳、DNS与时钟排障实战
运维·服务器·开发语言·人工智能·php·gpu算力
hsfxuebao1 小时前
深度拆解 TypeSafe Jev:System One 模型
人工智能
smartpi_ai1 小时前
机芯智能离线语音模块实战问答:SU-03T、CI-03T、SU-63T 与 JX-A7T 高频问题排查
人工智能·单片机·嵌入式硬件·ci/cd·语音识别
武子康1 小时前
LingBot-VLA 2.0 怎么从 6B 权重走到真实机器人?55 维动作只是起点
人工智能·后端·agent
曹牧1 小时前
Eclipse:Subclipse
java·ide·eclipse