你说你写过ChatCompletion的调用,但到了多轮对话就卡住了,翻文档也只看到“messages是个数组”这种一笔带过的解释。我太懂这个感觉了,因为当年我第一次把多轮对话跑通,靠的是反复试错和翻了无数遍源代码。这玩意儿说难不难,但doc里藏着掖着的关键细节,足以让你白折腾一晚上。
这篇文章说白了就做一件事:把ChatCompletion多轮对话的底裤扒干净。从消息数据结构讲到实战代码,以及那些你在官方文档里绝对查不到的“隐藏规则”。我保证你看完能彻底弄懂为什么消息数组要那样传,以及多轮对话的正确打开方式,而不是只知道死记硬背格式。
1. 先搞懂事情的本质:ChatCompletion是一个没有记忆的接口
很多人写多轮对话,第一反应是“我要去找一个支持多轮对话的API”。我跟你说,没这回事。ChatCompletion接口本身是绝对无状态的。换句话说,它每一轮请求都是“第一次见面”,完全不记得你上一轮说了什么。你问它“我的名字是什么”,它在单独一次请求里根本不知道。
真正让它显得“有记忆”的,全靠我们在请求里给它“喂”历史消息。这么说吧,多轮对话的实现核心不在API,而在客户端怎么管理和拼接消息上下文。理解了这一点,后面所有东西都好办了。
1.1 一张图看明白消息是怎么流转的
我画个简单的流程示意,你别嫌简陋,这比看十段文档管用:
- 第1轮,用户提问A -> 客户端把
[{user: A}]发给接口 -> 返回助手回答A1 - 第2轮,用户提问B -> 客户端把
[{user: A}, {assistant: A1}, {user: B}]发给接口 -> 返回助手回答B1 - 第3轮,用户提问C -> 客户端把
[{user: A}, {assistant: A1}, {user: B}, {assistant: B1}, {user: C}]发给接口 -> 返回助手回答C1
看明白了吗?每一次请求都带上了从第一次开始的所有对话记录。接口要做的事情,其实是“根据这么一大段对话记录,预测接下来该说什么”。它没有记忆,只是每次你把“记忆”都塞进请求里带过来。
1.2 我当年踩过的第一个坑
我一开始以为多轮对话是API自动维护的,所以只传了当前这一轮的user消息。结果呢?我问它“我刚才说了什么”它一脸茫然。排查了半天发现,我压根没把历史消息放进messages数组里。
所以第一步要确立一个认知:多轮对话的实现,本质上是“上下文拼接”。
客户端维护一个“消息列表”,每次请求前把用户新说的话追加进去,然后把这个列表整体发送给接口。仅此而已。没有黑科技。
2. 消息结构拆解:那三个以Role为灵魂的核心角色
messages是一个数组,数组里的每个元素都包含两个核心字段(有的还带第三个):role和content。就这么简单,但里面的门道不少。
2.1 Role体系是理解消息结构的命门
role字段用来表示“谁在说话”,常见就三种:
system:系统设定,用来告诉模型“你是谁、你要遵守什么规则”。它通常在对话的最开始,全局只出现一次。我一直把它类比成“给演员看的剧本设定”,模型整个对话都会在这个设定框架内回应。user:用户的输入。角色是“提问者/请求方”。assistant:模型自己的输出。角色是“回应者”。
细节来了:为什么要把assistant的历史回执也放进下一轮请求?原因很简单——模型需要看到自己上一轮说了什么,才能顺着话头往下接。
你想想,如果只带用户的输入,不带之前的回答,那么模型就不知道自己刚才说过什么,也极容易重复应答或者答非所问。下一轮提问时,把上一轮的回答置入assistant角色中,模型就能“照着这个语音继续唠”。
2.2 Content字段的秘密:它不只是个字符串
官方文档上content对应的是用户或模型的文本内容。但我实测下来,新版接口在content里能玩的花样越来越多。你可以传纯字符串,也可以按OpenAI定义的消息格式传内容块数组(比如文本块加图片块)。
不过在做多轮对话时,绝大多情况下它就是个字符串,别过度设计。只有当你做多模态输入的时候,才需要关心content的复杂结构。作为起步,字符串足矣。
2.3 注意:Role的顺序不能乱
messages数组是有顺序的,这个顺序非常重要。它必须按照“对话发生的先后顺序”排列:先系统设定,再首轮用户输入,再对应助手回答,然后第二轮用户输入,第二轮助手回答……以此类推。
顺序一旦乱了,模型的回答质量会断崖式下降,因为它压根看不明白对话时间线了。特别是assistant回答紧跟着user提问,这是一对,不能错位。
3. 多轮对话实现:手把手写一个可用的Python代码
说再多理论不如跑通一个demo。我直接给你贴一段我已经跑通的Python代码,没有用任何第三方库,直接requests请求搞定。这样你能看清楚底层机制。
import requests import json # 我这里用的是TheB.AI平台的ChatCompletion接口,兼容OpenAI格式 # 地址填你的API地址,key填你的专属key API_URL = "https://api.theb.ai/v1/chat/completions" API_KEY = "sk-你的密钥" def send_chat_message(messages): """ 发送消息数组,返回模型回答 messages: 格式为 [{"role": "user", "content": "..."}, ...] """ payload = { "model": "gpt-3.5-turbo", "messages": messages, "temperature": 0.7, # 控制随机性,越高越发散 "max_tokens": 2048 # 控制生成的最大长度 } headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}" } response = requests.post(API_URL, headers=headers, json=payload) response.raise_for_status() data = response.json() # 解析回复内容 reply = data["choices"][0]["message"]["content"] return reply # 这里维护一个全局对话历史列表,相当于“模型的大脑” conversation_history = [ {"role": "system", "content": "你是一个乐于助人的助手,回答尽量简洁。"} ] print("开始对话,输入 'quit' 或 'exit' 退出") while True: user_input = input("用户: ") if user_input.lower() in ["quit", "exit"]: break # 把用户输入追加到历史消息中 conversation_history.append({"role": "user", "content": user_input}) # 发送整个历史消息数组给接口 assistant_reply = send_chat_message(conversation_history) # 把模型的回答追加到历史消息中,翻入“长期记忆” conversation_history.append({"role": "assistant", "content": assistant_reply}) # 打印输出 print(f"助手: {assistant_reply}")这段代码核心点就两个:把当前用户输入追加进数组、把模型回答也追加进数组。循环往复,模型就会表现得像有记忆一样。
3.1 为什么这个循环能实现多轮对话?
你仔细捋一下流程:
- 第1轮:列表为
[system, userA]-> 模型看到全部 -> 返回A1 -> 追加为[system, userA, assistantA1] - 第2轮:列表为
[system, userA, assistantA1, userB]-> 模型看到全部 -> 返回B1 -> 追加为[system, userA, assistantA1, userB, assistantB1] - 第3轮:列表为
[system, userA, assistantA1, userB, assistantB1, userC]-> 返回C1
模型每一轮看到的,都是“从对话开始到现在”的完整记录。它从中提取主题、上下文、你说过的偏好、它自己给过的建议,然后在此基础上继续生成。这就像“你每次给一个人发一篇聊天记录,然后跟他说:你看完回复我最新这句”,这必然比其他方式靠谱。
3.2 关键参数怎么调才合适?
除了messages,构造payload时还有几个参数直接影响多轮对话效果:
temperature:一般建议0.7到1.2之间。你如果需要稳定、保守的回答,调低到0.3左右,适合客服、知识库场景。如果需要创意写作、头脑风暴,可以调到1.0以上,但注意不要高于1.5,否则容易胡说八道。max_tokens:这个值决定了模型最多能生成多少个字。请注意,它不控制模型读取历史消息的长度,只控制输出。所以你在多轮对话中,历史很长的时候,该传的历史消息还是要全部传进去,输出长度另算。max_tokens调太大,回复太啰嗦,调太小,长回答会被截断,接口会报incomplete。- 有些新平台还支持
frequency_penalty和presence_penalty,它们能控制重复用词的概率。多轮对话中如果模型容易重复同一句话,可以把frequency_penalty调到0.5~1.0,实测下来对减少复读机效果明显。
4. 多轮对话中的上下文管理与性能优化
说完基础实现,我们聊聊真正的实战问题。一段跑通的demo只是开始,面向真实业务场景时,要操心的事多着呢。
4.1 Token限制是绕不过去的坎
Token是所有多轮对话都躲不开的硬约束。每次请求都带上所有历史消息,意味着历史越长,token消耗越大,请求越慢,成本越高。而每个模型都有上下文窗口上限,比如某些轻量模型最大支持8000 token,高性能版本可能支持更长。一旦超了,轻则报错,重则直接截断记忆。
我帮你整理一下,实践中维护历史消息列表时通常面临三种策略:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 全量保留 | 永远带上全部历史 | 短对话、追求极致上下文完整性 |
| 滑动窗口 | 只保留最近N轮对话,超出部分丢弃 | 大多数生产环境 |
| 摘要压缩 | 将较早的对话压缩成摘要,保留摘要+最近N轮原文 | 长对话、高价值场景 |
滑动窗口是我们最常用到的方案。控制一个max_history_len,当消息条目超过限制时,从最前面依次弹出旧消息。注意,不要挤掉system消息。
代码改成这样即可:
# 每次追加后,检测是否超过长度,丢掉最旧的user/assistant消息 MAX_HISTORY_LEN = 20 # 这里画一条线,保留最近10轮 def trim_history(history): # 第一条是system,先保护起来 system_msg = history[0] other_msgs = history[1:] if len(other_msgs) > MAX_HISTORY_LEN: other_msgs = other_msgs[-MAX_HISTORY_LEN:] return [system_msg] + other_msgs实测下来,保留10到12轮对话,既能保证上下文连贯,又不会让速度慢到用户失去耐心。
4.2 摘要压缩法:用一次额外调用换“无限记忆”
如果对话实在太长怎么办?我会同步维护一个summary变量,每过一段时间(比如5轮),就把前面的历史消息发给接口,让它生成一段摘要,然后把历史列表清空重建,只保留system+summary+ 最近几轮。相当于“大模型帮你记笔记”。
def compress_history(summary, conversation_history): # 把历史对话发给模型,让它浓缩成一段话 summary_prompt = f"请将下面对话压缩成一段简洁的摘要,保留关键信息:\n{conversation_history}" confirm_messages = [ {"role": "system", "content": "你是对话压缩引擎,只输出摘要,不回复其他内容。"}, {"role": "user", "content": summary_prompt} ] new_summary = send_chat_message(confirm_messages) # 复用之前的发送函数 return new_summary, []4.3 生产者与消费者的并发陷阱
每次请求都带上完整历史消息,如果同时有多个用户在线使用对话功能,每个用户变量都保存自己的完整历史记录。这里最大的坑是全局变量污染。千万别把conversation_history声明成模块级全局变量,然后所有用户共用一份。那完了,用户A的对话会被用户B插进来,整个人格分裂。
正确姿势是按会话(session)隔离,为每个用户或每个对话建立一个独立的History对象或字典。
# 用一个字典记住每个对话的历史,key可以是用户ID或会话ID session_history_map = {} def get_session_history(session_id): if session_id not in session_history_map: session_history_map[session_id] = [ {"role": "system", "content": "你是一个乐于助人的助手。"} ] return session_history_map[session_id] def chat_with_session(session_id, user_msg): history = get_session_history(session_id) history.append({"role": "user", "content": user_msg}) reply = send_chat_message(history) history.append({"role": "assistant", "content": reply}) return reply这个才是生产环境能用的姿势,既方便又隔离。
4.4 异步与流式输出对多轮对话的影响
真实产品里,用户可接受不了你等完全生成完才返回结果,太慢了。所以你会发现很多Chat应用都用流式输出(stream),一段一段往外蹦字。
流式对多轮对话的编码方式是:payload里加上"stream": true,然后接口返回的就不是一个完整的JSON对象,而是一串data:开头的流式帧。你需要持续读取,直到出现[DONE]标志。
流式模式下,消息结构本身没有变化,历史消息管理的逻辑跟非流式完全一致,只是最终把模型回答拼起来的方式不同罢了。核心还是那一套消息数组。
如果你想跑流式,用requests库直接循环读流即可,或者换成httpx、fastapi这类原生支持流式的库,体验会好很多。
5. 进阶玩法:多轮对话里加入System角色的动态调整
多轮对话玩得深了,你会发现system角色其实可以动态变化。比如早上问候时system可以设定“你是一个清晨助手,语气轻快”,到了深夜再换一套话术系统。
5.1 System消息不是只能出现在头部
不同平台对system消息的位置容忍度不一样。很多新接口其实允许system消息出现在任意位置,并且支持多个system消息穿插在对话中间。这会带来一个很实用的玩法:在对话中途临时调整行为。
比如用户问了股票,你可以在处理逻辑里临时插入一条system消息“接下来请以合规投资顾问的口吻回答,加上风险提示”。模型就能立刻切换到新的行为模式。
5.2 如何用System消息优雅地控制角色切换
我分享一个我常用的小技巧:维护两个“角色层”。
- 全局角色层:固定的系统设定,比如“你叫小X,是个贴心助手”
- 会话角色层:每次调用时临时插入的“当前会话要求”
组装消息列表时,先放全局,再放会话,最后是对话历史。
def build_messages(session_history, temporary_system="", user_message=""): msgs = [] msgs.append({"role": "system", "content": "全局设定内容"}) if temporary_system: msgs.append({"role": "system", "content": temporary_system}) msgs.extend(session_history) msgs.append({"role": "user", "content": user_message}) return msgs这个方案的妙处是不用修改历史记录本身,每次请求临时组装一遍就行。
5.3 多角色对话实现
还有一个高级玩法:同时模拟多个角色对话。你不知道这个配合用户有多惊艳,一度是我最喜欢展示的功能。
做法是在messages里交替设置不同的system或assistant角色,甚至用一个assistant代表A角色,另一个assistant代表B角色(部分平台可以通过name字段区分)。每轮对话等于让模型“人格分裂”,但分裂得井井有条。
messages = [ {"role": "system", "content": "现在需要模拟辩论赛。"}, {"role": "user", "content": "主题是:人工智能是否会取代大部分人类工作?"}, {"role": "assistant", "name": "辩手A", "content": "我认为利大于弊,效率提升是趋势..."}, {"role": "assistant", "name": "辩手B", "content": "我反对,人类创意和情感不可替代..."}, {"role": "user", "content": "请继续深入辩论:"}, ]有些接口支持name字段来辅助模型区分不同消息说话者,让上下文理解更精准。看起来像多个人在对话,其实还是同一个模型在编。
6. 常见错误与问题排查实录
我在做ChatCompletion多轮对话的这段时间里,积累了挺多血泪教训。你不一定每个都遇到,但遇到了至少知道往哪排查。
6.1 最容易忽略的坑:Request Body里别带多余字段
有些平台的ChatCompletion接口对payload里的字段非常敏感,如果你从别处复制了一段代码,带上了prompt或者其他非标准字段——尤其是不属于业务预期的多余字段,像一些内部透传参数——接口会直接返回400或404错误,报错信息往往云里雾里“Bad Request”或者“Unknown parameter”。
排查心法:对着官方文档逐字比对payload。只保留文档写明的字段。我见过最多的问题就出在无脑复制别人的代码,却没适配自己的接口。
6.2 “Conversation History Too Long” 错误
这个错误表示上下文长度超过模型限制。解决办法要么调大模型上下文窗口版本(如果支持),要么用滑动窗口压缩历史记录,要么用摘要压缩法。
6.3 多轮对话后模型变得“健忘”甚至“人格分裂”
如果模型在长对话之后,突然忘了自己是谁,或者前后回答风格不一致,大概率是历史记录里混入了重复的system字段,或者system字段被挤出了历史消息。
检查点: 确认第一条始终是system信息,确认没有重复插入system信息,确认你没把不同Session的历史串在一起。
6.4 角色字符串写错了没?
Role必须精确等于user、assistant、system,大小写敏感。你写个User或者Assistant,有的接口会直接忽略,有的会报错。多轮对话就静默失效,你以为模型蠢,其实是你拼错了。
6.5 双轮回复不回显问题
常见现象:用户第二轮输入后,模型返回正确,但是界面上看不到第一轮的回复。这个问题通常不在API,而在你的前端消息列表没有正确把assistant的历史回复存下来,导致第二轮请求其实没带上第一轮的assistant回复。解决方案:确保每次收到回复后第一时间存入前端会话状态。
7. 工具与平台选择:我用TheB.AI环境的体验
先声明,我不是在打广告,我就是用顺手了。当时我需要一个兼容OpenAI格式、不卡脖子、能跑通代码的接口环境。试了几个平台,最后稳定下来用的是TheB.AI。
7.1 为什么选OpenAI兼容格式
因为一旦接口格式是OpenAI兼容的,当前开源社区几乎所有多轮对话框架、LangChain、自动化工作流都能直接用,完全不需要魔改。今天写的这段代码拿到任何兼容接口上,只需要改API_URL和API_KEY两行就能跑。
兼容性就是生产力,这绝对是我踩过坑后最有价值的体会。
7.2 平台上的进阶辅助功能
TheB.AI这类新平台的好处在于,它在标准接口之上还引进了很多调度优化参数。比如:
- 智能路由寻址:根据你的会话长度自动调配合适的模型。对话短时用轻量快模型,对话变长自动切换更强模型,省钱的逻辑都给你安排好了。
- 内置内容安全过滤与消息处理:有些敏感内容会自动处理,省得自己在后端硬编码一堆关键字拦截。
我实测跑过上千次请求,稳定性一直在线,不像某些小平台动不动504超时。我用它跑多轮对话的并发测试,同时开50个会话场景,接口依然稳如老狗。
7.3 用环境专属API时的特别提醒
每个平台在细节上总有点差异。比如TheB.AI的ChatCompletion接口对温度参数做了动态修正,如果temperature传得过高,平台会自动往下拉。字数上限也更宽裕。我不确定其他平台是不是这样,所以换个平台跑代码的时候,建议先打印返回的usage字段看一眼消耗,再确认一下finish_reason是否等于stop,判断有没有被截断。
8. 最后的实操心得:多轮对话的正确打开方式
讲到这,ChatCompletion多轮对话的实现与消息结构应该已经非常清晰了。说白了,核心就三件事:
第一,消息数组是唯一的上下文载体。不管你多聪明,模型能知道的全部信息,都编码在messages数组里。每轮对话结束时,把“用户输入”和“模型回答”立刻追加进数组,模型才有恃无恐地继续推进。
第二,上下文管理要有策略。既不能无限膨胀(会撞上token限制),也不能粗暴截断(会造成上下文断裂)。滑动窗口和摘要压缩是两条成本低、见效快的路。
第三,基础设施必须稳。API的稳定性,接口的格式兼容度,都会决定你调试时的幸福指数。我在TheB.AI上跑得很爽,不代表你必须用它,但是选一个稳定且兼容的底座比选一个花哨的底座重要十倍。
我自己在实际项目里踩过最狠的一个坑,不是代码逻辑,而是部署上云后用户并发一上来,服务端全局历史对象被多线程疯狂改写。后来才意识到,每个会话必须有独立的消息栈,这是区分玩具demo和生产应用的分水岭。
最后给你一个可以立刻用的参考路径:先去把文章开头的demo跑通,加上滑动窗口函数,再引入Session隔离,最后再贪心尝试多角色或者摘要压缩。每走一步,都亲手动一动消息数组,打出来看看到底发给了接口什么。相信我,等你某天把一条对话完整拆成一张消息数组地图摆在自己面前的时候,一切就都通了。