对话消息结构
🎯 学习目标
- 正确使用 system、user、assistant 角色组织 messages
- 实现可持久化的多轮对话状态管理
- 区分「展示给用户的历史」与「发送给模型的历史」
- 建立 System Prompt 版本化与窗口截断策略
引言
Chat Completions 不是「一个字符串 prompt」,而是 messages 数组——按时间顺序排列的多角色对话。模型看到完整上下文后,生成 下一条 assistant 消息。System Prompt 定全局人格与约束;User/Assistant 交替构成多轮记忆。搞错顺序、漏掉 assistant 历史、或把工具结果塞错 role,是 Agent 开发里最常见的 silent bug 之一。
本节从最小 JSON 结构讲起,实现一个 内存版 ChatSession,再讨论 Token 截断、摘要压缩、System Prompt 版本管理。你会理解:前端展示的聊天记录可以与发给模型的 messages ** intentionally 不同**——例如隐藏冗长 RAG 片段的展示样式,但 API 侧必须包含。
章节正文
第 1 步:messages 结构与角色语义
标准请求体核心:
{
"model": "gpt-4o-mini",
"messages": [
{ "role": "system", "content": "你是企业知识库助手。仅根据提供的上下文回答;不知道则说明。" },
{ "role": "user", "content": "什么是 RAG?" },
{ "role": "assistant", "content": "RAG 是检索增强生成……" },
{ "role": "user", "content": "它和微调有什么区别?" }
]
}role 说明:
- system:全局指令,通常放第一位;部分模型/厂商支持
developer等等价角色 - user:终端用户或「模拟用户的 pipeline 步骤」
- assistant:模型过往回复;多轮必须回传,否则模型「失忆」
不要把 assistant 内容伪造进 user——会破坏训练分布对齐,工具调用场景尤其混乱。
最小调用:
const messages = [
{ role: 'system', content: '回答用中文,不超过三句。' },
{ role: 'user', content: '解释 HTTP 和 HTTPS 区别' },
]
const res = await client.chat.completions.create({ model: 'gpt-4o-mini', messages })
const reply = res.choices[0].message.content
messages.push({ role: 'assistant', content: reply })
messages.push({ role: 'user', content: '那 TLS 在哪一层?' })
const res2 = await client.chat.completions.create({ model: 'gpt-4o-mini', messages })第二次请求 messages 长度增长——这就是多轮对话的本质状态机。
第 2 步:封装 ChatSession:追加与持久化
export class ChatSession {
constructor(systemPrompt, { maxTurns = 20 } = {}) {
this.messages = [{ role: 'system', content: systemPrompt }]
this.maxTurns = maxTurns // 仅统计 user-assistant 对
}
async send(client, userText, options = {}) {
this.messages.push({ role: 'user', content: userText })
const res = await client.chat.completions.create({
model: options.model ?? 'gpt-4o-mini',
messages: this.messages,
...options,
})
const assistant = res.choices[0].message.content ?? ''
this.messages.push({ role: 'assistant', content: assistant })
this._trim()
return { assistant, usage: res.usage }
}
_trim() {
const sys = this.messages[0]
const rest = this.messages.slice(1)
const maxMessages = this.maxTurns * 2
if (rest.length > maxMessages) {
this.messages = [sys, ...rest.slice(-maxMessages)]
}
}
toJSON() {
return this.messages
}
static fromJSON(arr) {
const s = new ChatSession('')
s.messages = arr
return s
}
}使用:
const session = new ChatSession('你是编程导师。')
await session.send(client, '什么是闭包?')
await session.send(client, '给一个 JavaScript 例子')生产可把 toJSON() 存 Redis/DB,按 sessionId 恢复。注意隐私与 retention 政策。
第 3 步:System Prompt 版本化与动态注入
System Prompt 是产品「性格」与合规边界的核心,应 版本化管理:
export const systemPrompts = {
'support-v1.2.0': `你是 XX 公司客服。
- 语气礼貌简洁
- 不承诺未公布的价格
- 无法确认时说「我需要为您核实」`,
'support-v1.3.0': `你是 XX 公司客服(2025-06 政策)。
- …新增退货条款…`,
}
function buildMessages(version, ragContext) {
const base = systemPrompts[version]
const system = ragContext
? `${base}\n\n## 检索上下文\n${ragContext}`
: base
return [{ role: 'system', content: system }]
}动态 RAG 通常拼进 system 或单独一条 user「参考以下资料:…」——两种皆可,Eval 选一种并固定。变更 version 时跑 回归测试(2.5 Eval 思想)。
记录 metadata:{ prompt_version, model, temperature } 便于追溯 bad case。
第 4 步:窗口溢出:截断、摘要与「展示 vs 发送」
当 messages Token 和接近 context limit,策略:
- 滑动窗口:只保留最近 N 轮(ChatSession 已演示)
- 摘要压缩:用模型把旧对话总结成一条 system/user 摘要再丢弃原文
- 向量记忆:长期事实写外部库,按需检索注入(第 4 章)
展示 vs 发送 示例:用户看到简洁 QA;模型需要完整 tool JSON:
// UI 历史
const displayHistory = [{ q: '北京天气', a: '晴,25°C' }]
// API messages(含 tool 原始结果,用户不可见)
const messages = [
{ role: 'system', content: '...' },
{ role: 'user', content: '北京天气' },
{
role: 'assistant',
content: null,
tool_calls: [{ id: 'call_1', function: { name: 'get_weather', arguments: '{"city":"北京"}' } }],
},
{ role: 'tool', tool_call_id: 'call_1', content: '{"temp":25,"cond":"晴"}' },
{ role: 'assistant', content: '北京今天晴,25°C。' },
]工具角色命名因厂商略异(OpenAI 用 tool);接入时读对应文档。
第 5 步:常见错误与调试技巧
错误 1:只有 user 没有 assistant 历史 → 模型重复回答或逻辑断裂。
错误 2:system 过长且每轮重复粘贴 RAG 全文 → 浪费 Token;应只注入 本次检索结果。
错误 3:把 JSON 指令放在 assistant → 应用 user 或 system。
调试:开发环境 log JSON.stringify(messages, null, 2)(注意脱敏);用 tiktoken 估 Token。
function approxCharsToTokens(text) {
// 粗算:中文约 1.5–2 char/token,英文约 4 char/token
return Math.ceil(text.length / 2)
}
function estimateMessagesTokens(msgs) {
return msgs.reduce((n, m) => n + approxCharsToTokens(m.content ?? ''), 0)
}上线用 API 返回的 usage.prompt_tokens 校准估算公式。
动手练习
- 实现 ChatSession 并连续问三个相关问题,验证模型能引用第一轮答案。
- 故意不设 assistant 历史再发第二轮,对比回答差异,写一句结论。
- 为 systemPrompts 添加 v1 与 v2,模拟政策变更并说明如何跑回归。
- 写函数
truncateByTokens(messages, budget):保留 system,从最新消息向前累加直到超 budget 前停止。
常见问题
Q:可以有多条 system 吗?
多数 API 建议单条 system 放首位;多条可能被合并或仅第一条生效。自定义内容合并成一条最稳妥。
Q:assistant 能留空吗?
工具调用轮次常 content 为 null 仅有 tool_calls;不要留空字符串占位完成整轮。
Q:用户编辑历史消息后要怎样同步?
从编辑点截断后续 assistant/user,重新生成;或 fork 新 sessionId,避免模型仍「记得」被删内容。
本节小结
Chat Completions 以 messages 数组维护 system/user/assistant 多轮上下文;assistant 历史必须回传。用 ChatSession 封装追加与截断,System Prompt 版本化并与 Eval 联动。长对话需滑动窗口或摘要;工具调用场景区分 UI 展示与 API 完整 messages。