Skip to content

对话消息结构

🎯 学习目标

  • 正确使用 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 结构与角色语义

标准请求体核心:

json
{
  "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——会破坏训练分布对齐,工具调用场景尤其混乱。

最小调用:

javascript
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:追加与持久化

javascript
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
  }
}

使用:

javascript
const session = new ChatSession('你是编程导师。')
await session.send(client, '什么是闭包?')
await session.send(client, '给一个 JavaScript 例子')

生产可把 toJSON() 存 Redis/DB,按 sessionId 恢复。注意隐私与 retention 政策

第 3 步:System Prompt 版本化与动态注入

System Prompt 是产品「性格」与合规边界的核心,应 版本化管理

javascript
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,策略:

  1. 滑动窗口:只保留最近 N 轮(ChatSession 已演示)
  2. 摘要压缩:用模型把旧对话总结成一条 system/user 摘要再丢弃原文
  3. 向量记忆:长期事实写外部库,按需检索注入(第 4 章)

展示 vs 发送 示例:用户看到简洁 QA;模型需要完整 tool JSON:

javascript
// 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。

javascript
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 校准估算公式。

动手练习

  1. 实现 ChatSession 并连续问三个相关问题,验证模型能引用第一轮答案。
  2. 故意不设 assistant 历史再发第二轮,对比回答差异,写一句结论。
  3. 为 systemPrompts 添加 v1 与 v2,模拟政策变更并说明如何跑回归。
  4. 写函数 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。