messages 角色与多轮对话构造详解
你大概率遇到过这种情况:接口调用明明没报错,模型却像”失忆”了一样,压根记不住你上一句说了什么;或者反过来,你换了个话题,它却死死揪着几轮之前的内容不放。这两种毛病,十有八九都出在 messages 这个参数没搭对。
messages 是 Chat Completions API 的核心参数,它是一个有序数组,每个元素代表对话中的一条消息。模型本身是无状态的——它不会替你记住任何东西,你每次调用传进去的 messages 数组,就是它这一次能看到的”全部记忆”。正确理解三种角色(system/user/assistant)并合理维护对话历史,是构建可靠多轮对话的基础,也是排查”上下文丢失""角色错乱”这类问题时第一个要检查的地方。
messages 结构
每条消息由 role 和 content 两个必填字段组成:
{
"messages": [
{ "role": "system", "content": "你是一个专业的代码审查助手。" },
{ "role": "user", "content": "帮我审查这段 Python 代码" },
{ "role": "assistant", "content": "好的,请把代码贴出来。" },
{ "role": "user", "content": "def add(a,b): return a+b" }
]
}
这里有个新手很容易忽略的细节:content 字段不是只能填字符串,它也可以是一个数组,每个元素带 type 字段。纯文本场景下你几乎不会用到这个形式,但只要你想传图片(多模态输入),就必须切换成数组写法:
{
"role": "user",
"content": [
{ "type": "text", "text": "这张图里的表格提取成 Markdown" },
{ "type": "image_url", "image_url": { "url": "https://example.com/table.png" } }
]
}
这里的坑在于:字符串写法和数组写法不能在同一次请求里随便混用错位。如果你的代码是”纯文本用字符串、带图时用数组”两套逻辑并存,很容易在某个分支忘了转换,导致报出类似 Invalid type for 'messages[2].content': expected one of a string or array 的 400 错误。排查时先打印一下你实际发出去的 content 字段类型,十次有八次问题就出在这——尤其是历史消息里混进了一条没做类型统一的记录。
三种角色对比
| 角色 | 职责 | 典型用法 | 注意事项 |
|---|---|---|---|
system | 设定模型行为、身份、约束 | 角色设定、输出格式要求 | 通常放在 messages[0];部分平台不支持多个 system |
user | 代表用户输入 | 用户的每一轮提问/指令 | 每轮必须有 user 消息 |
assistant | 代表模型的历史回复 | 把上一轮的回复追加进来 | 内容须与实际 API 返回一致,不要随意修改 |
除了这三种基础角色,如果你用到了 function calling / tool use(模型调用外部工具),还会遇到第四种角色 tool(在部分平台里叫 function),专门用来把工具执行结果回填进对话历史。它的位置有讲究:必须紧跟在触发它的那条 assistant 消息(带 tool_calls 字段)之后,中间不能插别的角色,否则模型会拿不到对应关系,报类似 “tool_call_id not found in previous message” 的错误。如果你的场景暂时用不到工具调用,可以先不管这一段,但心里要知道 messages 里能出现的角色不止三种。
角色的排列顺序也不是随便来的。多数平台要求 user 和 assistant 严格交替(system 除外,且只能出现在最前面),你不能连续塞两条 user 消息进去指望模型”看到更完整的输入”——有的平台会直接报错,有的平台虽然不报错但效果会明显变差,因为这打破了模型训练时见过的对话模式。如果你的业务逻辑需要把用户连续两次输入合并成一轮,正确做法是在拼接阶段把它们合并成一条 user 消息(比如用换行符拼接),而不是塞两条分开的记录。
单轮 vs 多轮对话
单轮:只有一条 user 消息,不需要历史上下文:
messages = [{"role": "user", "content": "什么是 token?"}]
多轮:每轮把 user 和 assistant 消息追加到列表,模型才能”记住”之前说了什么:
history = [
{"role": "system", "content": "你是一个编程助手,回答简洁。"}
]
def ask(question: str) -> str:
history.append({"role": "user", "content": question})
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=history,
max_tokens=512,
)
answer = resp.choices[0].message.content
history.append({"role": "assistant", "content": answer})
return answer
print(ask("Python 里怎么读文件?"))
print(ask("如果文件不存在怎么处理?")) # 能引用上一轮的上下文
这段代码里最关键的一行其实不是调用 API 那几行,而是最后的 history.append({"role": "assistant", ...})。我见过不止一个人调试半天”模型为什么记不住上一轮”,最后发现是漏了这一行——只把 user 消息追加进了历史,assistant 的回复只是打印出来展示,从没存回 history 列表。表现出来的现象是:第一轮问答完全正常,第二轮开始模型的回答变得像是”重新开始”,对第一轮内容毫无印象,但接口本身不会报任何错误,因为从 API 的角度看,你确实发了一个合法的 messages 数组,只是这个数组里根本没有上一轮的痕迹。排查这类问题最快的办法是在真正发请求前,把 history 完整打印一遍,看看条数对不对——如果一次问答后条数没有变成两条(一条 user 一条 assistant),那问题基本就锁定了。
还有一种相反的坑:忘了清空历史。如果你的服务是多用户共用一个进程,history 又被写成了全局变量或者模块级变量,那么用户 A 的对话内容会被用户 B 看到,甚至可能把 A 的敏感信息带进 B 的上下文里回复出来。正确做法是把 history 和会话(session)绑定,每个会话 ID 对应独立的一份历史,通常用 Redis 或数据库存储,而不是进程内存里的全局列表——上生产环境前一定要检查这一点,这是最容易被忽略、后果又最严重的问题之一。
messages 数量与 token 管理
模型的 context window(上下文窗口)是有限的,历史消息过多时需要截断:
| 策略 | 适用场景 | 实现方式 |
|---|---|---|
| 固定窗口截断 | 通用对话 | 保留 system + 最近 N 轮,丢弃中间历史 |
| 摘要压缩 | 长对话 | 定期把旧历史调用模型摘要,替换为一条 assistant 摘要消息 |
| 精确 token 计算 | 成本敏感 | 用 tiktoken 等工具计算后按 token 数截断 |
固定窗口截断示例:
MAX_PAIRS = 10 # 最多保留 10 轮对话(20 条消息)
def trim_history(history: list) -> list:
system = [m for m in history if m["role"] == "system"]
others = [m for m in history if m["role"] != "system"]
if len(others) > MAX_PAIRS * 2:
others = others[-(MAX_PAIRS * 2):]
return system + others
这个”固定窗口截断”的实现简单粗暴,但有个副作用你要提前想清楚:一刀切掉最早的几轮之后,如果第 3 轮里用户提到”我叫小王,账号是 138xxxx”,第 15 轮又问”我的账号是多少”,模型会答不上来,因为那条信息已经被截掉了。这不是 bug,是这种策略的固有取舍。如果你的场景里确实存在”早期信息后面还要用到”的情况,就不能只做固定窗口截断,得配合摘要压缩——摘要不是简单地把旧消息拼起来发给模型再要一句话总结,而是要在 prompt 里明确让模型提取”用户身份信息、已确认的关键事实、待办事项”这几类内容,不然摘要出来的东西一样会把关键信息丢了。
按 token 数截断比按轮数截断更精确,尤其是当你的对话里经常夹杂长代码块或长文档粘贴的时候——十轮对话里如果有一轮贴了几百行代码,“保留最近 10 轮”这种策略可能一下就把 context window 塞爆。用 tiktoken 做精确计算的思路大致是这样:
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o-mini")
def count_tokens(messages: list) -> int:
total = 0
for m in messages:
total += len(enc.encode(m["content"]))
total += 4 # 每条消息的角色、分隔符等开销,不同模型略有差异
return total
def trim_by_tokens(history: list, max_tokens: int = 6000) -> list:
system = [m for m in history if m["role"] == "system"]
others = [m for m in history if m["role"] != "system"]
while count_tokens(system + others) > max_tokens and others:
others.pop(0) # 从最早的一条开始丢
return system + others
注意这里每条消息我按 4 个 token 估算了角色和格式开销,这个数字不是精确值,不同厂商、不同模型的计费方式和实际 token 换算规则并不完全一致,做成本估算或者严格的 context 边界控制时,务必以你实际接入平台的官方计费口径为准,这里的写法只用来帮你建立”按 token 而不是按轮数管理历史”的思路。真到了生产环境,超限时的报错通常长这样:This model's maximum context length is 8192 tokens. However, your messages resulted in 9450 tokens——看到这个报错,第一反应不是加大 max_tokens 参数(那个控制的是输出长度,管不了输入),而是去检查你的历史截断逻辑是不是没生效,或者阈值设得太宽松。
流式对话下的 messages 维护
如果你的对话接口开了 stream=True,assistant 的回复不是一次性拿到的,而是一段一段的 delta 流式吐出来的。这时候维护历史的正确姿势是把所有 delta 片段拼接完整之后,再作为一条完整的 assistant 消息追加进 history,而不是把每个 delta 片段单独存成一条消息。我见过的一个真实翻车案例:某个项目把流式返回的每个 chunk 都追加成了一条独立的 assistant 消息,结果历史列表里出现了几十条 assistant 消息夹在两条 user 消息之间,破坏了角色交替的顺序,下一轮请求直接被平台拒绝,报的错类似 “messages must alternate between ‘user’ and ‘assistant’ roles”。正确做法是用一个局部变量把 delta 攒起来,等流结束(收到 finish_reason)之后,一次性拼成完整字符串再 append。
不同平台的 messages 格式差异
如果你只接一家模型,这一节可以跳过;但只要你打算做多平台切换或者聚合调用,就必须知道 messages 这套结构并不是所有厂商都完全照搬 OpenAI 的规范。最典型的差异在 Anthropic 的 Claude 系列接口上:
| 维度 | OpenAI 兼容格式 | Anthropic 格式 |
|---|---|---|
| system 提示词位置 | 放在 messages 数组第一条,role 为 system | 独立的顶层 system 参数,不放进 messages 数组 |
| messages 首条角色 | 无强制要求 | 必须以 user 开头 |
| user/assistant 交替 | 部分平台较宽松 | 强制严格交替,中间不能有多余角色打断 |
| 多模态 content | 数组里用 image_url 类型 | 数组里用 image 类型,且需要 base64 或专门的 source 字段 |
如果你写了一套基于 OpenAI 格式的对话管理逻辑,想直接换个 base_url 切到别的平台,遇到 400 报错先别急着怀疑 API Key 或者网络问题,大概率是 messages 结构没做适配——尤其是把 system prompt 也塞进了 messages 数组里传给 Anthropic 接口,这是最常见的一种”格式对不上”翻车场景。做多平台接入时,建议在你的代码里单独抽一层”消息格式转换”逻辑,而不是指望每个厂商都完全兼容同一套结构,具体每家平台的字段细节和是否支持某种角色,还是要以官方文档为准,接口会持续迭代。
常见问题
system 消息一定要有吗? 不是必须的,但强烈推荐:它能稳定模型的输出风格、限制回答范围,减少需要在 user 消息中重复说明的指令。
可以有多条 system 消息吗? OpenAI 官方规范允许,但部分兼容平台只识别第一条 system。建议将所有系统级指令合并为一条,放在 messages[0]。
assistant 消息内容可以手动编辑吗? 技术上可以,但会影响对话一致性。如果 assistant 历史与真实回复不一致,模型可能产生混乱输出;如有「引导模型输出」的需求,更推荐在 system prompt 中说明。
为什么第二轮问题模型”忘记”了第一轮? 每次 API 调用都是无状态的,“记忆”完全靠你把历史 messages 传回去。若只传了当前轮的 user 消息,模型自然不知道之前说了什么。
历史越长,回答质量一定越好吗? 不是。历史太长反而可能让模型在超长 context 里”注意力分散”,对早期的关键指令响应变弱,这也是为什么很多团队宁可做摘要压缩、把 system 里的关键约束反复强化,也不愿意无脑塞满整个 context window。另外历史越长,每次请求要重新处理的 token 越多,响应延迟和成本都会跟着涨——这笔账在设计截断策略时最好提前算清楚,而不是等线上账单出来才发现问题。
报错提示角色不对,具体该怎么排查? 遇到类似 “Invalid parameter: messages with role ‘system’ is not the first message” 或者 “roles must alternate” 这类报错,别急着改 SDK 版本,先把你实际发出去的 messages 数组完整打印出来,一条一条核对角色顺序:是不是 system 混在了中间、是不是连续出现了两条同角色的消息、是不是流式拼接时插入了多余记录。这个数组一旦打印出来,问题基本一眼就能看出来,比盲目猜测省时间得多。
更多接入方案见大模型 API 接入完全指南与接入教程专题。了解 system 提示词写法见system 提示词怎么设;参数调优见temperature/top_p/max_tokens 等参数详解。需要统一多模型入口?申请力达云聚合 API 内测。