MyContext 该不该存工具调用结果——看会话回放和上下文长度再定?

文章导读
工具调用结果要不要写进 MyContext,通常不靠“这条结果看起来重要”来判断,而是看两个可测量的事实:这条结果在后续轮次里有没有被再次引用,以及把它拼进上下文后是否逼近模型窗口上限。可以先跑一遍回放统计引用情况,再打印拼装后的长度,最后才在 full / summary / ref 三种策略里选一个。绝大多数多轮 Agent 场景里,全存和全不存都不是好选择,折中在于只对确实被二次引用的字段保
📋 目录
  1. 一 在会话回放里标记工具结果的后续引用次数
  2. 二 检查上下文长度预算和截断策略
  3. 三 用摘要或引用 ID 的通用代码骨架替换长结果
  4. 四 跑一轮回放回归验证取舍
A A

工具调用结果要不要写进 MyContext,通常不靠“这条结果看起来重要”来判断,而是看两个可测量的事实:这条结果在后续轮次里有没有被再次引用,以及把它拼进上下文后是否逼近模型窗口上限。可以先跑一遍回放统计引用情况,再打印拼装后的长度,最后才在 full / summary / ref 三种策略里选一个。绝大多数多轮 Agent 场景里,全存和全不存都不是好选择,折中在于只对确实被二次引用的字段保留原文。

先给默认值:工具结果默认只写“引用 ID + 摘要 + 关键字段”进上下文,原文落到外部存储;只有当回放显示某条结果在下一轮被逐字引用(比如要对比整段 JSON、拼接长文本)时,才对该字段改为保留原文。判断依据是引用次数加长度预算,验证方式是用同一份回放做全存与摘要对照,检查最终回答引用的字段是否一致。边界是:摘要会丢字段,取原文会增加一次读取。

在会话回放里标记工具结果的后续引用次数

核心动作是给每条工具结果分配一个稳定 ID,然后在下一轮拼装上下文的地方打点,记录“哪个 ID 被引用了、引用了哪个字段”。字段粒度比整条粒度有用得多,因为常见情况是一轮里只需要 data.items[].sku 或 content 的一小段,而不是整份返回体。

如果你的 Agent 已经有会话记录(消息列表、trace、或日志),可以直接在回放脚本里遍历:对每个 tool_result,扫它之后的所有轮次,看这个 ID 是否出现在模型输入或工具参数里。记录格式可以简单些:

{"turn": 7, "refs": [
  {"result_id": "r_102", "field": "data.items[].sku"},
  {"result_id": "r_103", "field": "content"}
]}

跑完一轮历史会话,你会得到一张“引用次数 → 建议策略”的判断表,可以按自己数据填:

MyContext 该不该存工具调用结果——看会话回放和上下文长度再定?
回放表现建议策略验证方式风险边界
后续轮次从未引用ref(只留 ID)下一轮仍能按 ID 取回原文取原文失败时回答会空转
被引用 1 次,且只用到个别字段summary + 关键字段白名单对照最终回答字段是否一致摘要可能漏掉白名单外的字段
被引用 2 次以上,或需逐字比对full(保留原文)打印拼装长度确认不超预算上下文被长结果挤占,挤压历史
体积很大但字段固定ref + 按需取字段断言按 ID 取到的键存在需要外部存储可稳定读

检查上下文长度预算和截断策略

策略不能只看引用次数,还要看拼完后还剩多少余量。建议先把预算写成显式配置,而不是散在代码里:

context:
  max_tokens: 32000        # 占位值,按你实际使用的模型窗口填
  reserve_for_output: 2000 # 给模型输出留的空间
  tool_result_policy: ref  # full | summary | ref
  truncate: head_tail      # none | head | head_tail

验证方式是在发请求之前打印拼装后的长度,每次调用都打,而不是只在出问题时打:

assembled = build_messages(session, policy=cfg.tool_result_policy)
length = estimate_tokens(assembled)
print("est_tokens=", length, "budget=", cfg.max_tokens)
assert length < cfg.max_tokens - cfg.reserve_for_output

estimate_tokens 用你已有的分词器或近似估算即可,重点是比较“当前长度 vs 预算 - 输出预留”。如果断言经常被触发,说明 full 策略在这个会话里放不下,应该先调 tool_result_policy,再看截断策略是不是把历史消息切得太狠。截断只解决放不放得下的问题,不等于降低调用成本,这两件事要分开看。

MyContext 该不该存工具调用结果——看会话回放和上下文长度再定?

用摘要或引用 ID 的通用代码骨架替换长结果

下面是可替换的接入骨架,思路是“原文落外部存储,摘要和关键字段写进上下文”,summarize、extract_keys、blob_store 都换成你自己的实现:

def store_tool_result(raw_text, session):
    result_id = session.new_result_id()      # 稳定、可回查
    blob_ref = blob_store.put(raw_text)      # 原文落外部存储
    session.tool_results[result_id] = {
        "ref": blob_ref,
        "summary": summarize(raw_text),      # 规则抽取或本地模型
        "keys": extract_keys(raw_text),      # 供下一轮定位字段
    }
    return result_id, session.tool_results[result_id]["summary"]

def render_for_context(result_id, session, policy):
    rec = session.tool_results[result_id]
    if policy == "full":
        return blob_store.get(rec["ref"])
    if policy == "summary":
        return rec["summary"]
    return f"[tool_result id={result_id} keys={rec['keys']}]"

def resolve(result_id, session):
    rec = session.tool_results[result_id]
    return blob_store.get(rec["ref"])

raw = resolve("r_102", session)
assert raw and len(raw) > 0              # 断言下一轮能定位到原文

要点有三个:result_id 必须稳定,回放和线上用同一套生成规则;keys 里放的是能被查询的字段名或路径,让模型知道“原文里有这些东西”;resolve 是唯一取原文的入口,方便在取不到时打日志而不是静默返回空串。如果 summarize 用的是模型,注意它本身会带来延迟和不确定性,摘要内容尽量不要作为唯一事实来源。

MyContext 该不该存工具调用结果——看会话回放和上下文长度再定?

跑一轮回放回归验证取舍

决定用哪种策略前,用同一份工具结果分别跑 full 和 summary,比较最终回答里引用到的字段集合:

for policy in ("full", "summary"):
    out = run_turn(session_clone, policy=policy, turn=8)
    print(policy, sorted(out.cited_fields))

如果摘要版本少了订单号、金额、状态、ID 这类关键实体,说明 extract_keys 的白名单漏了字段,应该把这些字段补进白名单;如果少了的是大段说明性文本,通常可以接受。这一步的关键是“同一份输入、同一轮提问、只改 policy”,否则比较结果没有意义。

验证通过后再灰度切换策略比较稳妥。切换回滚也很简单:把 tool_result_policy 改回 full 即可,因为原文一直落在外部存储里,历史会话不会因此损坏。需要注意的是,回放环境里的工具返回和线上不一定一致,策略定下来后仍要结合具体环境的返回体积再确认一次。