Gemini 4 Argon 长会话丢前文 / 先把历史裁剪与摘要策略定下来

文章导读
多轮对话跑到十几轮后,Gemini 4 Argon 不再遵守前面说过的设定,通常只有两种可能:客户端在拼请求时把早期消息裁掉了,或者累计输入超过了模型可接受的输入上限,被接入层或服务端截断。先不要改提示词,也不急着换模型,把自己每轮真正发出去的 messages 完整落盘,再和本地保存的完整历史逐条比对,先确认丢失发生在哪一步。
📋 目录
  1. A 把每轮真正发给模型的 messages 完整打日志
  2. B 对比客户端保存的历史长度与实际发出的长度
  3. C 设定裁剪规则:保留系统提示、最近若干轮、关键事实摘要
  4. D 把被裁掉的关键信息改写成结构化摘要再拼回请求
  5. E 用同一组多轮输入做回归,比较裁剪前后是否还引用早期设定
A A

多轮对话跑到十几轮后,Gemini 4 Argon 不再遵守前面说过的设定,通常只有两种可能:客户端在拼请求时把早期消息裁掉了,或者累计输入超过了模型可接受的输入上限,被接入层或服务端截断。先不要改提示词,也不急着换模型,把自己每轮真正发出去的 messages 完整落盘,再和本地保存的完整历史逐条比对,先确认丢失发生在哪一步。

处理顺序建议是“先日志、再比对、后定规则”:把每轮实际发送的 messages 落盘,对比客户端历史条数与实际发送条数。若保存 20 轮只发 8 轮且正好是尾部,说明裁剪在客户端;条数一致仍丢前文,多半是输入超限或摘要环节。规则上保留系统提示加最近若干轮加结构化事实摘要,并用固定输入集复跑验证。具体保留轮数与字符预算需结合模型输入上限和业务容忍度确认。

把每轮真正发给模型的 messages 完整打日志

日志要打在“即将发请求的那一份 messages”上,而不是打在你以为最终会发出去的那份变量上。需要记录的字段至少包括:轮次、消息条数、全部消息 content 的总字符数、是否含系统提示,以及每条消息的角色序列。轮次用来和客户端保存的历史对齐;条数最直观;总字符数用来估算输入预算;是否含系统提示能直接看出裁剪有没有把 system 一起切掉。

# log_msgs.py
import json, time, hashlib

def dump_turn(turn_id, messages, path='msgs_turn%02d.json' % turn_id):
    # 1) 原文落盘,供事后逐条比对
    with open(path, 'w', encoding='utf-8') as f:
        json.dump(messages, f, ensure_ascii=False, indent=2)
    # 2) 汇总字段打印一行,便于按轮次看趋势
    summary = {
        'turn': turn_id,
        'msg_count': len(messages),
        'total_chars': sum(len(m.get('content') or '') for m in messages),
        'has_system': any(m.get('role') == 'system' for m in messages),
        'roles': [m.get('role') for m in messages],
        'digest': hashlib.sha1(
            json.dumps(messages, ensure_ascii=False).encode('utf-8')
        ).hexdigest()[:12],
        'ts': time.strftime('%Y-%m-%dT%H:%M:%S'),
    }
    print(json.dumps(summary, ensure_ascii=False))

# 在调用模型前一行执行
dump_turn(12, messages)

把 dump_turn 放在 SDK 调用之前、所有裁剪和摘要逻辑之后。roles 字段能帮你快速发现“系统提示消失”“历史被倒序拼进去”这类低级问题,digest 则用来确认同一轮日志有没有被重复写入。

对比客户端保存的历史长度与实际发出的长度

客户端通常自己维护一份完整历史,另一个变量才是最终发给模型的。把两个文件放在一起比对,就能定位裁剪发生在哪一步。

# compare.py
import json

saved = json.load(open('client_history.json', encoding='utf-8'))  # 客户端全量历史
sent  = json.load(open('msgs_turn20.json', encoding='utf-8'))     # 第 20 轮实际发出

fp = lambda m: (m.get('role'), (m.get('content') or '')[:40])
saved_fp, sent_fp = [fp(m) for m in saved], [fp(m) for m in sent]
print('saved:', len(saved), 'sent:', len(sent))
print('sent 等于 saved 尾部:', sent_fp == saved_fp[-len(sent_fp):])
print('sent 等于 saved 头部:', sent_fp == saved_fp[:len(sent_fp)])
print('被丢掉的是:', saved_fp[:max(0, len(saved_fp) - len(sent_fp))])

判读方式:如果客户端保存了 20 轮、第 20 轮实际只发出最近 8 轮,而且这 8 轮正好是保存历史的尾部,说明裁剪发生在客户端的拼接函数里,去查那个 max_turns 或滑动窗口参数即可。如果发出的不是尾部而是头部,或者顺序对不上,问题在拼接逻辑而不是长度限制。如果两个长度一致、模型仍然忘记早期设定,就要看请求是否在网关或服务端被二次截断,以及被裁掉的内容有没有已经被摘要替换掉。

设定裁剪规则:保留系统提示、最近若干轮、关键事实摘要

保留内容需要有明确优先级,建议按“系统提示 > 关键事实摘要 > 最近若干轮原文 > 更早的原始对话”排列。更早的原始对话不是直接丢弃,而是压缩进事实摘要。裁剪规则写成显式函数,才好按业务调整,也才好做回归。

KEEP_RECENT_TURNS = 6      # 最近 6 轮问答
MAX_CHARS = 12000          # 文本预算,按所用模型输入上限留余量

def build_messages(system_prompt, history, facts):
    recent = history[-KEEP_RECENT_TURNS * 2:]   # 一问一答算两条
    msgs = [{'role': 'system', 'content': system_prompt}]
    if facts:
        msgs.append({'role': 'system', 'content': render_facts(facts)})
    msgs += recent
    while sum(len(m['content']) for m in msgs) > MAX_CHARS and len(recent) > 2:
        recent = recent[2:]                     # 从最老的一轮开始丢
        msgs = msgs[:2] + recent
    return msgs

def render_facts(facts):
    lines = ['以下是本次会话已确认的事实,回答时必须遵守:']
    for k, v in facts.items():
        lines.append('- %s: %s' % (k, v))
    return '\n'.join(lines)

关键事实用固定键名的 dict 存放,键名建议固定为:角色设定、项目背景、已确认结论、硬性约束、未完成事项。值写成短字符串或短列表,不要塞原始对话片段。这样每次更新都是对同一结构做覆盖,摘要不会随轮次无限膨胀。KEEP_RECENT_TURNS 和 MAX_CHARS 需要结合实际模型的输入上限和业务容忍度确认,不要照抄。

Gemini 4 Argon 长会话丢前文 / 先把历史裁剪与摘要策略定下来

把被裁掉的关键信息改写成结构化摘要再拼回请求

行数受限时,早期设定靠摘要保住。摘要用固定提示词生成,输出结构化字段,不要让它自由发挥写一段话。

你负责压缩一段多轮对话,只输出 JSON,不要输出解释。
只保留会影响后续回答的信息,丢弃寒暄、重复内容和已被推翻的说法。
字段固定为:
{"role_setting": "", "background": "", "decisions": [],
 "constraints": [], "open_items": []}
没有信息的字段用空字符串或空数组。每条不超过 60 字。
对话内容:
---
{这里放被裁掉的历史原文}
---

字段设计上,role_setting 放对模型的身份或语气要求,background 放项目与场景信息,decisions 放已经确认的结论,constraints 放禁止项和硬性要求,open_items 放待办。拼回请求时的位置建议是:原始系统提示 → 事实摘要 → 最近 N 轮原文,摘要作为独立的 system 消息,不要和用户消息合并成一条,否则模型容易把摘要当成用户刚说的话。长会话里每轮都对“被裁掉的部分 + 上一版摘要”做一次压缩,摘要就能跟着会话推进更新,而不是只有第一次有效。

用同一组多轮输入做回归,比较裁剪前后是否还引用早期设定

策略改完必须有可复跑的验证,否则只能靠感觉。固定输入集的构造方式:把一份 10 到 15 轮的对话脚本存成 JSON 数组,每项是 {role, content},并在第 2 轮埋一个无法从后文推出的设定,比如一个自定义命名或一条禁用项。回放时逐轮调用 build_messages 并实际发起请求,确保走的是真实链路。

判定标准有两条:到第 12 轮左右问一个只有第 2 轮说过的设定,看模型回答是否复现该设定;同时检查该轮 messages 日志里,这个设定是否以原文或摘要字段的形式存在。两条都通过,才算策略生效;只满足其中一条,说明摘要丢了信息或摘要没被模型采纳。

  1. 保持输入集文件不变,改动只发生在裁剪参数或摘要提示词上;
  2. 清掉本地摘要缓存,避免用旧摘要跑新规则;
  3. 重新回放,逐轮保存 messages 日志;
  4. 对比上一版的 msg_count 与 total_chars,确认改动确实生效;
  5. 记录判定结果,只按判定结果决定是否采纳新参数。

判定不通过时,排查顺序建议是:先看摘要字段里是否真的保留了那条早期设定,再看 KEEP_RECENT_TURNS 是否偏小,最后才考虑调大字符预算。调预算之前,确认所用模型当前的输入上限,避免把请求做成必然超限。