本文来自《AI 应用开发课程》月份 1 课程文档,已整理为网站文章版本。
学习目标
学完本节后,你应当能够:
- 理解
system、user、assistant、tool四类消息的角色。 - 明白多轮对话为什么需要手动拼接消息历史。
- 学会设计月份 1 使用的
ChatMessage结构。 - 了解最基础的上下文裁剪思路。
前置知识
- 已能发出最小 DeepSeek 请求
1. messages 是什么
大多数聊天类 LLM API 都把输入组织为消息列表:
[
{"role": "system", "content": "你是一个研发助手。"},
{"role": "user", "content": "请解释 FastAPI 和 Flask 的差异。"}
]
这让模型知道:
- 你对它的角色要求
- 用户说了什么
- 之前已经发生过什么
2. 常见角色
system
定义模型身份、边界、输出要求。
user
用户的请求。
assistant
模型之前的回答。
tool
工具执行结果,通常在 Tool Calling 场景下回填给模型。
3. 多轮对话为什么要拼接历史
聊天 API 通常是无状态的。也就是说,服务端不会自动记住你上一次说了什么。你需要把历史一起发过去。
示例:
messages = [
{"role": "user", "content": "世界最高的山是什么?"},
{"role": "assistant", "content": "珠穆朗玛峰。"},
{"role": "user", "content": "第二高呢?"},
]
如果只发送最后一句“第二高呢?”,模型可能根本不知道你在问什么。
4. 课程统一消息模型
推荐在 app/models.py 中定义:
from typing import Literal
from pydantic import BaseModel
ChatRole = Literal["system", "user", "assistant", "tool"]
class ChatMessage(BaseModel):
role: ChatRole
content: str
5. 上下文裁剪为什么重要
随着对话越来越长,会出现这些问题:
- token 成本增加
- 延迟增加
- 无关历史干扰当前回答
月份 1 不做复杂记忆系统,但你必须先懂最基础裁剪策略。
6. 最小裁剪策略
策略一:只保留最近 N 轮
简单直接,适合入门。
策略二:固定保留 system prompt + 最近若干轮
更实用,因为 system prompt 通常不能丢。
示例:
def trim_messages(messages: list[ChatMessage], max_items: int) -> list[ChatMessage]:
if len(messages) <= max_items:
return messages
system_messages = [message for message in messages if message.role == "system"]
non_system_messages = [message for message in messages if message.role != "system"]
kept = non_system_messages[-max_items:]
return system_messages[:1] + kept
这不是生产级算法,但足够建立月份 1 的上下文管理意识。
7. 实操任务
- 定义
ChatMessage - 手动构造 3 轮对话消息列表
- 实现
trim_messages - 对比裁剪前后的消息内容
8. 自测题
- 为什么多轮对话不能只传最后一句用户输入?
- 为什么 system prompt 通常应该优先保留?
- 为什么月份 1 只做“最小裁剪”,而不做复杂记忆?
9. 作业与验收
作业:
- 在
prompt_lab中实现一个message_history.py,包含消息构造和裁剪函数。
验收标准:
- 能表达多轮消息
- 能执行最小裁剪
- 裁剪逻辑清晰,不把 system prompt 意外丢掉
10. 常见错误
- 只传最后一轮 user 消息
- 把 assistant 历史漏掉
- 裁剪时把关键 system prompt 删了
11. 本章与前文关系
上一章让你知道“如何向模型发送请求”,这一章要解决更重要的问题:请求里真正关键的数据不是 URL,而是 messages。
对于聊天类模型来说,消息结构本身就是程序和模型之间的协议。你如果不理解它,就很容易把 LLM API 简化成“发一段 prompt 字符串”,这会在后面多轮对话、结构化输出和 Tool Calling 中全部出问题。
12. 本章在研发助手项目中的位置
研发助手项目中的:
- CLI 输入
- FastAPI
ChatRequest - Tool Calling 回填
- 多轮对话上下文
全部都建立在 ChatMessage 这个抽象之上。你可以把本章理解为“后面所有链路的共同数据结构课”。
13. 基于官方文档确认的一个关键事实
DeepSeek 官方多轮对话文档明确说明:
/chat/completions是无状态接口,服务端不会记住之前的上下文;用户每次请求都需要自行拼接历史消息。
这件事非常重要,因为它意味着:
- 消息历史由你的程序负责维护
- 多轮对话不是“自动记忆”
- 上下文裁剪是你的系统设计问题,而不是模型帮你处理的问题
来源:
14. 错误示例 vs 正确示例
错误示例:把多轮对话简化成“只发最后一句”
messages = [
{"role": "user", "content": "第二高的山是什么?"}
]
如果前文刚聊完“世界最高的山”,这里只发最后一句,模型未必知道你在接着问什么。
正确示例:保留必要历史
messages = [
{"role": "user", "content": "世界最高的山是什么?"},
{"role": "assistant", "content": "珠穆朗玛峰。"},
{"role": "user", "content": "第二高呢?"},
]
现在模型才能更稳定理解上下文。
15. 完整文件级示例:message_history.py
"""消息构造、追加入历史和最小裁剪示例。"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
ChatRole = Literal["system", "user", "assistant", "tool"]
class ChatMessage(BaseModel):
role: ChatRole
content: str
def build_initial_messages(system_prompt: str, user_input: str) -> list[ChatMessage]:
"""创建一轮对话的初始消息。"""
return [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", content=user_input),
]
def append_assistant_reply(
messages: list[ChatMessage],
assistant_output: str,
) -> list[ChatMessage]:
"""将模型回复追加到历史中。"""
return messages + [ChatMessage(role="assistant", content=assistant_output)]
def append_user_message(
messages: list[ChatMessage],
user_input: str,
) -> list[ChatMessage]:
"""将新的用户输入追加到历史中。"""
return messages + [ChatMessage(role="user", content=user_input)]
def trim_messages(messages: list[ChatMessage], max_non_system_messages: int) -> list[ChatMessage]:
"""保留第一条 system 消息和最近若干条非 system 消息。"""
system_messages = [message for message in messages if message.role == "system"]
non_system_messages = [message for message in messages if message.role != "system"]
kept_non_system = non_system_messages[-max_non_system_messages:]
return system_messages[:1] + kept_non_system
16. 逐段解释这份完整示例
为什么 system 消息单独对待
因为它通常定义模型角色和输出边界。很多时候它比单轮对话内容更重要,不应轻易丢掉。
为什么追加历史要分成多个函数
这是在训练一种非常重要的工程习惯:把“修改消息历史”的动作显式化,而不是在一堆列表操作里随意 append。
为什么裁剪的是“非 system 消息数”
因为对于月份 1 来说,最常见也最稳的策略,就是保住第一条 system prompt,再裁剪最近几轮对话。
17. 一个更接近研发助手场景的消息示例
messages = [
{"role": "system", "content": "你是一个严谨的研发助手。"},
{"role": "user", "content": "请总结下面这个 git diff。"},
{"role": "assistant", "content": "这是一个关于日志模块的改动。"},
{"role": "user", "content": "请进一步给出 commit message。"},
]
这个例子很重要,因为它说明了:
- 月份 1 的消息结构不是抽象聊天玩具
- 它直接服务于研发助手的真实工作流
18. 为什么上下文裁剪是系统设计问题
很多初学者会想:
模型不是很聪明吗?为什么不让它自己“记住重点”?
问题在于:
- token 成本是你的系统成本
- 无关历史会拖慢响应
- 不是所有历史都对当前任务有用
所以你迟早要做:
- 保留哪些内容
- 丢弃哪些内容
- 是否摘要旧历史
月份 1 只讲最小裁剪策略,但这已经足够建立正确工程直觉。
19. 调试与排错:本章最常见问题
问题一:system prompt 丢失
现象是模型风格突然漂移、格式要求失效、角色不稳定。
问题二:没有把 assistant 历史放回去
现象是多轮对话衔接很差,看起来像每轮都“重新开始”。
问题三:消息对象与 API body 结构不一致
这是后面 FastAPI 和 LLM client 对接时很常见的问题,所以现在就要保持消息结构稳定。
20. 本章完成后你应该具备的能力
完成本章后,你应当做到:
- 能解释为什么聊天 API 本质上是消息列表协议。
- 能构造和维护最小多轮历史。
- 能实现基础上下文裁剪。
- 能把消息结构和后续
ChatRequest联系起来。
21. 如果你卡在这里,先回看哪几章
- Pydantic 模型还不稳:回看 03-pydantic与配置建模.md
- LLM 基本调用链不清楚:回看 01-LLM基本概念与DeepSeek接入.md
22. 从本章过渡到下一章的桥接说明
接下来进入 03-结构化输出与JSON Schema.md。
你现在已经理解了输入如何组织。下一步要解决输出问题:当模型返回内容时,如何让这份结果既对人可读,又对程序可处理。这也是从“普通聊天”升级到“工程系统输入输出设计”的关键一步。