多轮对话到后半段开始答非所问,通常不是模型突然变差,而是每次请求实际送入的 messages、token 计数和采样参数在变。先把服务端记录和参数对照调出来:若历史被截断,优先解决上下文结构;若上下文完整,再用单变量试验查采样设置。
多轮后期跑偏,先看服务端日志里实际送入的 token 数与截断字段,确认历史消息是否被丢弃或单条裁剪。上下文完整时,固定提示只改 temperature、top_p 等一项,逐项记录输出。同一段历史重放两次,可复现的偏离多半指向截断、消息拼装或状态缺陷;不可复现则先怀疑采样随机性。边界是不同网关和模型版本行为有差异,需按实际配置验证。
在会话记录里确认上下文被截断的位置
先排除截断,因为截断造成的跑偏最有规律:模型会反复问已经回答过的问题,或者丢掉 system 里的角色设定。在服务日志或应用层包装里,至少记录这些字段:
- 实际送入的 token 数量,例如 prompt_tokens 或 input_tokens;
- max_context_tokens 或模型上下文上限;
- truncated 布尔值、truncate_strategy、dropped_messages 数量。
如果网关不输出这些字段,可以在请求发出前打印 messages 数组长度和每条 content 长度。通用检查骨架:
def log_request(messages, max_context_tokens):
total = sum(estimate_tokens(m['content']) for m in messages)
print({
'messages_count': len(messages),
'roles': [m['role'] for m in messages],
'prompt_tokens': total,
'max_context_tokens': max_context_tokens,
'truncated': total > max_context_tokens,
})
判断丢弃最早消息还是截断单条长消息,看 messages 数组的形态。消息条数变少、最早几轮 user/assistant 消失,通常是丢弃最早消息。条数不变但某条 content 长度明显变短,总 token 下降,通常是截断单条长消息。把日志里的 messages 和用户端会话记录逐轮对齐,丢失位置就是截断发生的位置。token 计数受分词实现影响,需要留出输出预留,不能按刚好等于上限来配。
固定提示只改采样参数做对照
确认送入上下文完整后,把变量分离出来。固定 system 提示、固定同一段历史消息、固定最后一条用户问题,只改一个采样参数。建议从 temperature 和 top_p 开始,再单独看 presence_penalty、frequency_penalty、max_tokens。每次记录 request_id、参数、输入 messages 的哈希或轮次编号、输出全文、人工判定是否跑偏。
一个可执行的对照表骨架:
base:
temperature: 0.7
top_p: 0.9
presence_penalty: 0.0
frequency_penalty: 0.0
max_tokens: 1024
cases:
- name: temp_0
change: {temperature: 0.0}
- name: top_p_0.5
change: {top_p: 0.5}
- name: no_penalty
change: {presence_penalty: 0.0, frequency_penalty: 0.0}
每次只改一项,改完重放同一段历史。如果 temperature 降到 0 附近、top_p 收窄后跑偏明显减少,说明采样随机性贡献较大;如果参数逐项降下来仍然跑偏,回到上下文截断、消息角色拼装或提示词矛盾上查。max_tokens 设得太小会让回答被硬截,看起来也像答非所问,需要单独确认输出是否完整。
把同一段历史重放两次看是否可复现
重放做法:把同一段历史 messages、同一组采样参数、同一模型名称保存下来,连续发两次或多次,记录每次 request_id 和返回。为了降低随机性,可以先把 temperature 设为 0 或接近 0,但要知道部分后端即使 temperature=0 也可能存在轻微差异,所以不能只凭一次不同就定性。
比对方式:逐字或语义 diff,看偏离从第几轮开始,是否总是丢掉同一个约束、同一个实体或同一个待办。可复现的偏离通常指向逻辑缺陷:上下文被截断、system 提示被滑窗丢弃、消息角色拼错、工具调用结果没有回填、摘要覆盖了关键约束。不可复现的偏离优先怀疑采样设置:temperature 过高、top_p 过宽、惩罚项导致用词漂移。风险边界是并发、缓存和模型版本切换也会制造不可复现差异,需要把请求时间和模型版本一起记录。
给多轮会话加摘要或滑窗
从结构上延长可用轮数,两个常用做法是摘要回填和滑窗保留最近轮次。system 提示要始终保护,不参与滑窗丢弃;历史摘要作为单独 system 消息放在 system 之后,再拼接最近轮次。通用写法骨架:
SYSTEM_PROMPT = '...'
MAX_TURNS = 8
def build_messages(history, new_user):
system = [{'role': 'system', 'content': SYSTEM_PROMPT}]
recent = history[-MAX_TURNS:]
older = history[:-MAX_TURNS]
summary = summarize(older) if older else None
msgs = list(system)
if summary:
msgs.append({'role': 'system', 'content': '历史摘要:' + summary})
msgs.extend(recent)
msgs.append({'role': 'user', 'content': new_user})
return trim_to_token_budget(msgs)
摘要回填后要检查关键信息有没有丢:用户目标、约束条件、已确认决策、命名和数字、未完成任务、禁忌项。滑窗保留轮数不是越多越好,保留太多会挤占输出预留;保留太少会丢约束。关键约束可以同时保留原文短句,不要只依赖摘要。摘要更新频率也要控制,频繁调用摘要模型会增加延迟和成本,可以在轮数超过阈值时触发。
把参数与上下文策略写进配置
把采样参数和上下文策略从代码里拿出来,放进配置文件或配置中心,便于调整和追溯。建议暴露这些项:模型名称、max_context_tokens、reserve_output_tokens、truncate_strategy、max_turns、summary_enabled、summary_trigger_turns、temperature、top_p、presence_penalty、frequency_penalty、max_tokens、seed(如果接口支持)、log_prompt_tokens、log_dropped_messages。
默认值取舍:temperature 和 top_p 取偏保守值,减少多轮后期用词漂移;reserve_output_tokens 必须预留,避免上下文顶满后输出被截断;truncate_strategy 在长会话场景优先用滑窗加摘要,短会话可以只用滑窗;摘要默认可以先关闭,等确认历史长度确实超过上下文后再开启。配置示例:
chat:
model: 'your-model-name'
context:
max_context_tokens: 8192
reserve_output_tokens: 1024
truncate_strategy: 'sliding_window'
max_turns: 8
summary:
enabled: true
trigger_turns: 10
sampling:
temperature: 0.3
top_p: 0.8
presence_penalty: 0.0
frequency_penalty: 0.0
max_tokens: 1024
logging:
log_prompt_tokens: true
log_dropped_messages: true
变更后需要重新观察:被截断请求比例、实际 prompt token 分布、同一输入重放一致性、人工标注的跑偏次数、摘要后关键信息丢失案例、用户纠正次数。观察窗口内不要同时改多个参数,否则无法判断是哪一项在起作用。不同模型和网关对上下文与采样参数的实现有差异,最终以自己环境里的日志和重放结果为准。