Hy4 在多轮场景下最常遇到的问题,是把单轮能跑的请求写法直接套到第二轮:服务端按无状态处理,请求里不带历史,它自然就不知道上一轮说过什么。判断路径可以很短——先用两次连续请求确认服务端到底有没有替你保存上下文;确认不保存之后,把历史消息在客户端维护成消息数组,按固定规则裁剪后随每次请求一起发出去。这条路适用的场景是多轮对话需要自己控制上下文内容、需要复现同一段对话;边界在于历史越长请求体越大,越容易撞上模型的上下文长度上限,裁剪规则没定好就会把关键信息切掉。
单轮问答直接把用户输入发出去即可;多轮对话不建议依赖服务端记忆。做法是在客户端维护 messages 数组,把每轮 user 内容和模型回复按 role 拼回请求体,再按轮次或字符数裁剪。判断依据是第二次请求的输出里有没有出现上一轮的信息,验证方式用两次连续请求就能完成,不需要依赖任何文档承诺。
用两次连续请求验证服务端是否记住上一轮
验证的目标只有一个:搞清单轮请求需不需要自己带上历史消息。构造方式是用同一个账号、同一套参数发两次请求,第一次塞一个容易被识别的信息,第二次只问这个信息,两次之间不要改模型名、温度或任何可能影响路由的参数。
# 第一次:只发本轮内容
POST /v1/chat
{
"model": "hy4",
"messages": [
{"role": "user", "content": "记住一个口令:蓝鲸-731。不用解释。"}
]
}
# 第二次:不带历史,直接追问
POST /v1/chat
{
"model": "hy4",
"messages": [
{"role": "user", "content": "刚才让你记住的口令是什么?"}
]
}
判定依据就看第二次输出里有没有出现“蓝鲸-731”。出现了,说明这条接入路径可能带会话状态,需要再开一个新对话或换一个会话标识重发一次,排除是连接复用或缓存造成的假象;没出现、或者模型回答“你没有告诉我”,就可以按无状态接口来设计。如果请求体里本来就有 session_id、conversation_id 这类字段,先确认字段是否真的被接受——会话字段是否存在以实际请求结果为准,不要凭字段名推断。
还有一种反向验证更直接:第二次请求只带历史、不带原始问题,看模型能否接着上文回答。这一步能顺带验证消息数组的格式是否被正确解析。
在客户端维护消息数组并定裁剪规则
确认服务端不保存上下文之后,多轮对话的上下文来源就只剩客户端这一处,消息数组必须由业务代码自己维护,并且每次请求把完整数组发出去。
{
"model": "hy4",
"messages": [
{"role": "system", "content": "你是客服助手,回答控制在三句以内。"},
{"role": "user", "content": "订单 A1024 什么时候发货?"},
{"role": "assistant", "content": "A1024 目前处于待发货状态,预计两个工作日内出库。"},
{"role": "user", "content": "能改收货地址吗?"}
]
}
结构上有几个点要固定下来:role 取值一般用 system、user、assistant 三种,助手角色名要按接口实际接受的写法来,有的接口用 assistant,有的用 model,写错会被当成用户消息处理;content 必须是字符串,不要把整个响应对象直接塞进去;system 提示词建议固定放在数组第一位,并且不参与裁剪。
裁剪策略建议分两层。第一层按轮次保留,只保留最近若干轮完整的 user + assistant 对,具体轮数按模型上下文长度和单轮平均长度估算;第二层按字符数兜底,当累计字符超过阈值时,从最老的一对消息开始成对删除,避免出现只有 user 没有 assistant 的孤立消息。成对删除是关键,只删一半会让模型看到自己没回答过的问题,输出容易跑偏。
把上一轮的模型回复按角色拼回请求体
拼接这一步的目标是让格式与单轮请求完全一致,只多出若干条历史消息。做法是拿到响应后取文本内容,按 assistant 角色追加到数组末尾,再把新的用户输入追加进去。
// 伪代码,替换点:响应文本的取值路径按实际返回结构调整
const history = [{ role: "system", content: SYSTEM_PROMPT }];
function ask(text) {
history.push({ role: "user", content: text });
const trimmed = trimHistory(history); // 裁剪后再发
const resp = await chat({ model: "hy4", messages: trimmed });
const answer = resp.choices[0].message.content; // 只取文本
history.push({ role: "assistant", content: answer });
return answer;
}
常见格式错位有三类,都值得在联调时盯一眼。角色名写错,比如写成 bot、AI、model 之外的自造值,接口可能直接报错,也可能静默当成 user 处理,表现为模型不停自问自答。空内容,多见于流式拼接没接完或工具调用返回空文本,把一个 content 为空的 assistant 消息塞进去,会让后续轮次的行为不稳定。顺序颠倒,多见于把回复先 push 到数组开头或并发写入时插错位置,检查方式是打印整个数组,逐条确认 user 与 assistant 严格交替、system 只在首位。
对比自维护历史与平台会话能力的返回差异
下面的对比基于同一组三轮对话:第一轮给出口令,第二轮追问口令,第三轮要求把口令和本轮问题一起复述。两种方式的差异主要在请求体内容和可复现性上,平台是否提供会话能力以实际请求结果为准。
| 关注点 | 自维护历史 | 平台会话(若该接口确实支持) |
|---|---|---|
| 第二轮请求体 | 携带 system + 第一轮 user/assistant + 第二轮 user | 可能只需带会话标识和本轮 user 内容 |
| 上下文由谁负责 | 客户端,业务代码完全可见可改 | 服务端,客户端能看到的部分有限 |
| 裁剪控制 | 自己定轮次和字符阈值,行为可预测 | 通常由服务端策略决定,具体规则需要实测 |
| 同一段对话复现 | 把数组原样再发一次即可复现 | 取决于会话是否可重建,一般较难完全复现 |
| 排查“它忘了”的问题 | 打印请求体就能定位 | 需要看会话记录或服务端日志 |
| 切换模型或供应商 | 数组结构基本可平移 | 会话机制往往绑定具体平台 |
如果验证结果是没有会话能力,自维护历史就是唯一选项,不用再纠结;如果平台确实支持会话,选择依据是业务是否需要精确控制上下文长度和复现同一段对话——需要,就走自维护历史;只是做简单问答、不关心上下文细节,用平台会话更省事。
给历史裁剪加阈值并做一次长对话回放
阈值取值没有通用数字,建议这样估:先查模型允许的上下文长度上限,减去 system 提示词和本轮预留的输出空间,剩下的额度除以单轮平均字符数,得到一个轮次上限,再取一个比它保守的值作为条数阈值,同时设一个字符数阈值做兜底。两个阈值都写在配置里,不要硬编码在业务逻辑中。
const LIMITS = {
maxTurns: 8, // 保留最近 8 轮对话
maxChars: 6000 // 累计字符兜底,超出从最老一对开始删
};
function trimHistory(msgs) {
const system = msgs.filter(m => m.role === "system");
let rest = msgs.filter(m => m.role !== "system");
// 先按轮次裁
if (rest.length > LIMITS.maxTurns * 2) {
rest = rest.slice(rest.length - LIMITS.maxTurns * 2);
}
// 再按字符裁,成对删除
while (rest.length > 1 &&
rest.reduce((n, m) => n + m.content.length, 0) > LIMITS.maxChars) {
rest.splice(0, 2);
}
return [...system, ...rest];
}
回放流程建议这样走:准备一段二十轮左右的长对话脚本,在靠前的几轮里埋入两三个关键信息(例如订单号、地址、约定时间),然后逐轮发送,每轮都打印实际发出的 messages 数组长度和字符总数。观察指标看四项——请求是否因超长报错、关键信息在第几轮之后开始答不出、裁剪后数组是否仍严格保持 user 与 assistant 交替、system 提示词是否始终保留。发现关键信息过早丢失,就调高阈值或改成保留摘要;发现请求体持续逼近上限,就调低轮次阈值。这一步不需要复杂工具,一个循环脚本加日志输出就够了。