多轮对话跑到十几轮后,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 需要结合实际模型的输入上限和业务容忍度确认,不要照抄。
把被裁掉的关键信息改写成结构化摘要再拼回请求
行数受限时,早期设定靠摘要保住。摘要用固定提示词生成,输出结构化字段,不要让它自由发挥写一段话。
你负责压缩一段多轮对话,只输出 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 日志里,这个设定是否以原文或摘要字段的形式存在。两条都通过,才算策略生效;只满足其中一条,说明摘要丢了信息或摘要没被模型采纳。
- 保持输入集文件不变,改动只发生在裁剪参数或摘要提示词上;
- 清掉本地摘要缓存,避免用旧摘要跑新规则;
- 重新回放,逐轮保存 messages 日志;
- 对比上一版的 msg_count 与 total_chars,确认改动确实生效;
- 记录判定结果,只按判定结果决定是否采纳新参数。
判定不通过时,排查顺序建议是:先看摘要字段里是否真的保留了那条早期设定,再看 KEEP_RECENT_TURNS 是否偏小,最后才考虑调大字符预算。调预算之前,确认所用模型当前的输入上限,避免把请求做成必然超限。