本文是一次个人技术复盘,公开版本全部脱敏;文中案例数字只作为单场景历史记录引用,不推为通用结论。配套最小示例独立编写,不依赖任何内部仓库。
副标题:不把完整大数组喂给模型------截断、旁路缓存与参数注入的工程取舍
引子:Agent 调工具,第一个踩坑是"什么都想喂给模型"
LLM Agent 最容易被忽视的成本,不是模型调用本身,而是工具返回什么就往 prompt 里塞什么。
工具返回的常常是数组、JSON、表格、日志、格点数据;而模型做决策真正需要的,往往只是统计量、极值、是否命中、有没有越界。把完整结构直接拼进上下文,会同时带来三个问题:
-
窗口被挤占:长明细占据绝大部分上下文预算,留给任务指令、历史摘要和后续工具结果的空间所剩无几;
-
有效指令被淹没:关键判断条件混在大段明细里,模型需要"在噪声里找规则",注意力被分散;
-
成本与延迟失控:每次调用都把超长明细重新过一遍 token 预算,对话越长,浪费越累积。
本文复盘一个真实遇到过的场景:一个行业工具返回了一份体量很大的结构化结果,我们在不丢弃完整数据的前提下,把模型可见的上下文压到了决策需要的程度。下面先讲问题复盘,再拆三个治理手段、几处取舍和容易踩的坑。
问题复盘:大数组进 prompt 之后发生了什么
现象
工具返回一份长列表 / 格点结构的结果。模型上下文被这份主体占据,后续指令与新的工具结果在窗口中互相挤压:要么早期内容被挤出窗口,要么关键判断条件排在明细之后、模型难以聚焦。
体量记录(单场景历史记录)
这是某一个历史场景留下的记录值,先把口径讲清楚:
-
该工具结果约 419,682 字符 ,对应 prompt token 记录为 917,403;
-
做上下文治理之后,该场景 prompt token 记录为 6,945。
边界声明(单独说明,避免误读)
这是某一个历史场景的记录值,不是批量压测或通用统计;
同一份历史记录明确:在截断这个阶段之后,该场景的后续工具并没有继续调用 ;因此这组 token 差异只证明上下文体量下降,不代表工具链被端到端续接成功,也不是"模型自主恢复了完整链路";
它不是通用成本改善率,不能据此推算"整体上下文成本下降多少";
本文不提供准确率、召回率、响应延迟等评测数字------这些在当时没有完整评测集支撑,本文只讲机制与取舍。
把口径锁死之后,再看工程上具体做了什么。
治理手段一:工具返回主体截断,模型只看决策需要的信息
截断不是粗暴砍半,而是按工具类型决定保留什么、截掉什么:
-
截掉:大数组主体、重复字段、逐条明细记录;
-
保留:头部摘要、关键统计量(总数、均值、极值、阈值越界条数)、命中标记、来源标识;
-
不直接丢弃:截断后必须在模型可见内容里留一个"完整副本在哪里"的引用,而不是把明细删掉就结束。
最小示例:模型可见部分 = 统计量 + 头部 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 融合的教学样例)