← Back to 文字

消息结构与上下文管理

本文来自《AI 应用开发课程》月份 1 课程文档,已整理为网站文章版本。

学习目标

学完本节后,你应当能够:

  • 理解 systemuserassistanttool 四类消息的角色。
  • 明白多轮对话为什么需要手动拼接消息历史。
  • 学会设计月份 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. 实操任务

  1. 定义 ChatMessage
  2. 手动构造 3 轮对话消息列表
  3. 实现 trim_messages
  4. 对比裁剪前后的消息内容

8. 自测题

  1. 为什么多轮对话不能只传最后一句用户输入?
  2. 为什么 system prompt 通常应该优先保留?
  3. 为什么月份 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. 本章完成后你应该具备的能力

完成本章后,你应当做到:

  1. 能解释为什么聊天 API 本质上是消息列表协议。
  2. 能构造和维护最小多轮历史。
  3. 能实现基础上下文裁剪。
  4. 能把消息结构和后续 ChatRequest 联系起来。

21. 如果你卡在这里,先回看哪几章

22. 从本章过渡到下一章的桥接说明

接下来进入 03-结构化输出与JSON Schema.md

你现在已经理解了输入如何组织。下一步要解决输出问题:当模型返回内容时,如何让这份结果既对人可读,又对程序可处理。这也是从“普通聊天”升级到“工程系统输入输出设计”的关键一步。

Fin