← 返回资讯

messages 角色与多轮对话构造详解

2026-06-24

你大概率遇到过这种情况:接口调用明明没报错,模型却像”失忆”了一样,压根记不住你上一句说了什么;或者反过来,你换了个话题,它却死死揪着几轮之前的内容不放。这两种毛病,十有八九都出在 messages 这个参数没搭对。

messages 是 Chat Completions API 的核心参数,它是一个有序数组,每个元素代表对话中的一条消息。模型本身是无状态的——它不会替你记住任何东西,你每次调用传进去的 messages 数组,就是它这一次能看到的”全部记忆”。正确理解三种角色(system/user/assistant)并合理维护对话历史,是构建可靠多轮对话的基础,也是排查”上下文丢失""角色错乱”这类问题时第一个要检查的地方。

messages 结构

每条消息由 rolecontent 两个必填字段组成:

{
  "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 里能出现的角色不止三种。

角色的排列顺序也不是随便来的。多数平台要求 userassistant 严格交替(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 内测