【LangChain实战】LangChain 学习笔记(二):结构化输出、流式传输与消息管理

👋 欢迎阅读

🏠个人主页: 愿旖旎

📘专栏传送门: 算法专栏

💻当前学习内容:LangChain

📑 目录


一、前置讲解

在进入正文前,先花 10 秒了解本篇会反复用到的核心概念,带着印象去读正文,学习效率更高。

🧠 前置知识一:结构化输出

让模型按你定义的结构(JSON/Pydantic)返回数据是程序对接模型的关键

📐 前置知识二:三种结构定义方式

Pydantic / TypedDict / JSON Schema,决定了返回的是对象还是字典

前置知识三:流式传输

用 stream/astream 逐块输出结果显著改善实时体验

🗨️ 前置知识四:消息(Messages)

System/Human/AI/Tool 四种消息,是聊天模型的输入输出格式

🧮 前置知识五:上下文窗口与历史管理

多轮对话靠"重发历史",trim/filter/merge 是管理历史的三个工具


二、聊天模型:结构化输出

LangChain 中,聊天模型提供了额外的功能:结构化输出 。这是一种使聊天模型以结构化格式 (例如 JSON )进行响应的技术。例如,可能希望将模型输出存储在数据库中,并确保输出符合数据库模式

这样做的核心原因 是:从**"字符串"** 到**"对象"** 的范式转换 。想象一下,在没有这个功能之前,我们调用聊天模型得到的是一个 AIMessage ,其内容是一个字符串

python 复制代码
model = ChatOpenAI()
response = model.invoke("告诉我关于苹果公司的最新消息。")
print(response.content)
# 输出: "苹果公司于昨日发布了新款iPhone...其股价上涨了2%..."

这个字符串 对人类很友好,但对程序 不友好。如果我们想从这段文本中提取出 "公司名""股价变化" 并用在后续逻辑中,则需要编写复杂且容易出错 的解析代码(例如,使用正则表达式 )。聊天模型的 with_structured_output 方法则允许我们预先定义 一个期望的数据结构 ,并要求大模型必须按照这个结构返回信息。

2.1 with_structured_output()

LangChain 中,要使用结构化输出 能力,提供了 .with_structured_output() 方法。该方法需要先定义输出结构 ,然后执行通过 .with_structured_output() 得到的 Runnable 实例。步骤如下(伪代码):

python 复制代码
# 1. 定义输出结构
schema = {"foo": "bar"}
# 2. 绑定 schema,其实是生成支持结构化返回的 Runnable 实例
model_with_structure = model.with_structured_output(schema)
# 3. 执行
structured_output = model_with_structure.invoke(user_input)

这是获得结构化输出 的最简单、最可靠的方法。此方法将输出结构 作为参数输入,返回一个类似 modelRunnable 。不同之处在于执行 Runnable 后的输出结果,输出的不是字符串消息 ,而是与给定输出结构 相对应的对象

三种输出结构定义方式

方式 写法 返回结果
TypedDict class Xxx(TypedDict) 字典
JSON Schema Python 字典描述结构 字典
Pydantic class Xxx(BaseModel) Pydantic 对象

函数参数:

参数 说明
schema 输出结构:JSON、TypedDict、Pydantic、OpenAI 函数/工具
method LLM 的生成方法json_schema(默认)、function_callingjson_mode
include_raw False(默认)仅返回解析结果,出错抛异常;True 返回含 "raw"/"parsed"/"parsing_error" 的字典
strict True 保证输出与 schema 完全匹配;False 不验证;None(默认)不传递
tools 绑定到模型的工具列表(要求 method=json_schema、strict=True、include_raw=True)
kwargs(Any) 附加参数,直接传递给 bind()

method 三种取值:

method 说明
json_schema(默认) 使用 OpenAI 的结构化输出 API
function_calling 使用 OpenAI 的工具调用(DeepSeek 需指定此方式)
json_mode 使用 OpenAI 的 JSON mode(须在提示中说明输出格式)

返回值 :返回一个 Runnable 实例。

  • 如果 include_raw=False :schema 是 Pydantic 类则输出 Pydantic 对象 ,否则输出字典

  • 如果 include_raw=True :输出含三个键的字典 ------'raw'(原始 BaseMessage)、'parsed'(解析结果,出错为 None)、'parsing_error'(解析异常,无则 None)。

2.2 Pydantic 对象

将执行 Runnable 后的输出结果 指定为 Pydantic 类,将返回一个 Pydantic 对象。当收到模型的响应后,LangChain 会提取出代表 Pydantic 参数的 JSON 对象,并用 Pydantic 模型对其进行解析和验证 ,最终将这个验证后的 JSON 转换为一个可用的 Pydantic 对象实例返回。

python 复制代码
import os
from langchain_openai import ChatOpenAI
from typing import Optional
from pydantic import BaseModel, Field

# ========== 1. 初始化模型 ==========
model = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"),   # Key 从环境变量读
base_url="https://api.deepseek.com",     # DeepSeek 接口地址
temperature= 1.5
)

# ========== 2. 定义输出结构(Pydantic 类) ==========
# 告诉模型"你必须按这个结构返回":字段、类型、描述
class Joke(BaseModel):
"""给用户讲一个笑话。"""
setup: str = Field(description="这个笑话的开头")
punchline: str = Field(description="这个笑话的妙语")
rating: Optional[int] = Field(default=None, description="从1到10分,给这个笑话评分")

# ========== 3. 把模型包装成"结构化输出"模型 ==========
# 关键:必须指定 method="function_calling"!
# 默认的 json_schema 方式 DeepSeek 不支持
# function_calling = 模型按"工具调用"的方式返回 JSON,DeepSeek 支持
structured_model = model.with_structured_output(Joke, method="function_calling")

# ========== 4. 调用并取结果 ==========
result = structured_model.invoke("给我讲一个关于唱歌的笑话")
print(result)              # 返回的是 Joke 对象
2.3 TypedDict

先了解 一下 TypedDict ,它用于为字典对象 提供精确的、结构化的类型提示 。它允许我们指定一个字典中应该有哪些 ,以及每个键对应的值的类型 。最清晰、最常用的定义方式,就是类似于定义一个(Python 3.8+):

python 复制代码
from typing import TypedDict
​
class User(TypedDict):
    name: str
    age: int
    email: str
    is_active: bool = True  # 默认值

这有什么⽤呢?对于它⾮常重要的⼀个能⼒就是捕捉键名拼写错误与类型错误

同样,我们也可以将执行 Runnable 后的输出结果 指定为 TypedDict 类,这将返回一个字典 ,且输出后,会根据 TypedDict 的定义进行验证

python 复制代码
import os
from langchain_openai import ChatOpenAI
from typing import Optional
from typing_extensions import Annotated, TypedDict

# ========== 1. 初始化模型 ==========
model = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"),   # Key 从环境变量读
base_url="https://api.deepseek.com",     # DeepSeek 接口地址
temperature=1.5,                         # 温度高一点,讲笑话更有创意(范围 0~2)
)

# ========== 2. 定义输出结构(TypedDict) ==========
# TypedDict = 只描述"结构长什么样"的字典类型,比 Pydantic 更轻量
class Joke(TypedDict):
"""给用户讲一个笑话。"""                           
setup: Annotated[str, ..., "这个笑话的开头"]         
punchline: Annotated[str, ..., "这个笑话的妙语"]      
rating: Annotated[Optional[int], None, "从1到10分,给这个笑话评分"]  

# ========== 3. 把模型包装成"结构化输出"模型 ==========
# 关键:必须指定 method="function_calling"!
# 默认的 json_schema 方式 DeepSeek 不支持(400 报错)
structured_model = model.with_structured_output(Joke, method="function_calling")

# ========== 4. 调用并取结果 ==========
result = structured_model.invoke("给我讲一个关于跳舞的笑话")
print(result)                    # 返回的是【字典】dict(不是对象!)
print("开头:", result["setup"])  # 字典用 [键名] 取值(注意!不是 .字段名)

让我们加入 include_raw=True,再来看看效果:

复制代码

可以看到:raw 是模型原始响应(含 tool_calls),parsed 是解析后的字典,parsing_error 无错误时为 None。

2.4 返回 JSON

还可以让聊天模型 直接返回 JSON ,只不过为了声明 JSON,我们需要定义 JSON Schema

python 复制代码
import os
from langchain_openai import ChatOpenAI

# ========== 1. 初始化模型 ==========
model = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"),   # Key 从环境变量读
base_url="https://api.deepseek.com",     # DeepSeek 接口地址
temperature=1.5,                         # 温度高一点,讲笑话更有创意(范围 0~2)
)

# ========== 2. 定义输出结构(JSON Schema 字典) ==========
# 这是结构化输出的第三种写法:直接用 JSON Schema 描述结构
# 结构说明:
#   title       = 结构名字
#   description = 整体描述(模型参考)
#   type        = 顶层是对象 object
#   properties  = 字段列表:每个字段写 类型 + 描述
#   required    = 必填字段清单(没列进去的就是可选的)
json_schema = {
"title": "joke",
"description": "给用户讲一个笑话。",
"type": "object",
"properties": {
   "setup": {
       "type": "string",
       "description": "这个笑话的开头",
   },
   "punchline": {
       "type": "string",
       "description": "这个笑话的妙语",
   },
   "rating": {
       "type": "integer",
       "description": "从1到10分,给这个笑话评分",
       "default": None,               # 可选字段(不在 required 里)
   },
},
"required": ["setup", "punchline"],    # 只有 setup 和 punchline 是必填
}

# ========== 3. 包装成结构化输出 ==========
# 关键:必须指定 method="function_calling"!
# 默认的 json_schema 方式 DeepSeek 不支持(400 报错)
structured_model = model.with_structured_output(json_schema, method="function_calling")

# ========== 4. 调用并取结果 ==========
result = structured_model.invoke("给我讲一个关于唱歌的笑话")
print(result)                    # 返回字典:{'setup': ..., 'punchline': ..., 'rating': ...}
2.5 使用场景:信息提取器
python 复制代码
import os
from langchain_openai import ChatOpenAI
from typing import Optional
from pydantic import BaseModel, Field
from langchain_core.messages import HumanMessage, SystemMessage

# ========== 1. 初始化模型)==========
model = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)

# ========== 2. 定义输出结构(Pydantic 类) ==========
# 信息提取场景的关键技巧:
#   1. 每个字段都用 Optional ------ LLM 不知道答案时返回 None(不硬编)
#   2. 每个字段都有 description ------ LLM 根据描述去文本里找信息
class Person(BaseModel):
"""一个人的信息。"""
name: Optional[str] = Field(default=None, description="这个人的名字")
hair_color: Optional[str] = Field(default=None, description="如果知道这个人头发的颜色")
skin_color: Optional[str] = Field(default=None, description="如果知道这个人的肤色")
height_in_meters: Optional[float] = Field(default=None, description="以米为单位的高度")

# ========== 3. 包装成结构化输出 ==========
# 关键:必须指定 method="function_calling"!默认方式 DeepSeek 不支持(400)
structured_model = model.with_structured_output(schema=Person, method="function_calling")

# ========== 4. 准备消息:SystemMessage 教规则 + HumanMessage 给原文 ==========
messages = [
SystemMessage(content="你是一个提取信息的专家,只从文本中提取相关信息。"
                     "如果您不知道要提取的属性的值,属性值返回null"),
HumanMessage(content="史密斯身高6英尺,金发。"),
]

# ========== 5. 调用并取结果 ==========
result = structured_model.invoke(messages)
print(result)                    # Person 对象:name='史密斯' hair_color='金发' skin_color=None ...
print("名字:", result.name)      # 对象用 .字段名 取值
print("身高(米):", result.height_in_meters)

三、聊天模型:流式传输

流式处理 对于基于 LLM 的应用程序能够响应最终用户 至关重要。它通过逐步显示输出 ,即使在完整的响应准备就绪之前,流式传输也能显著改善用户体验 。我们之前直接使用 invoke 的调用方式属于非流式传输 ,现象是聊天模型直接返回全量内容。若模型思考时间较长,则我们等待的时间就越长。

方式 现象 比喻
invoke 等模型全部生成完,一次性返回全量内容 像收快递,等半天一次拿到
stream 模型生成一个字就给一个字 像看直播,逐字出现
3.1 stream() 同步流式传输

LangChain 聊天模型中,可以使用其 .stream() 方法,来同步生成流式响应的效果。聊天模型的 .stream() 方法返回一个迭代器 ,该迭代器在生成输出时同步产生输出消息块 。可以使用 for 循环实时处理每个块。

python 复制代码
import os
from langchain_openai import ChatOpenAI

# ========== 1. 初始化模型 ==========
model = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"),   # Key 从环境变量读
base_url="https://api.deepseek.com",     # DeepSeek 接口地址
)

# ========== 2. 流式输出 ==========
# 和 invoke 的区别:
#   invoke  = 等模型全部生成完,一次性返回(像收快递,等半天一次拿到)
#   stream  = 模型生成一个字就给你一个字(像看直播,逐字出现)
chunks = []
for chunk in model.stream("讲一个笑话"):
chunks.append(chunk)                     # ① 收集每一块(后面可以拼成完整文本)
print(chunk.content, end="|", flush=True) # ② 实时打印:end="|" 不换行,flush=True 强制立即显示

print()                                      # 循环结束后换行
print("分块数量:", len(chunks))              # 看看一段话被切成了几块
print("完整内容:", "".join(c.content for c in chunks))   # 把块拼回完整文本

我们得到了一个叫做 AIMessageChunk 的东西,它代表 AIMessage 的一部分,也就是消息块

python 复制代码
print(chunks[0] + chunks[1] + chunks[2] + chunks[3] + chunks[4])
3.2 异步相关概念

对于流式传输,通常我们可以选择异步调用。 先来了解下异步相关知识。

想象一个场景:你需要煮一壶水 ,同时还要给朋友发一条短信 。我们分别用同步(传统)异步 两种方式来完成,以此对比并引入协程事件循环的概念。

同步(阻塞)方式 :这就像是一个"死心眼"的人,做事必须一件一件来 ------先花 5 秒煮水(期间什么也不能做),水开后再花 2 秒发短信,总耗时 7 秒

python 复制代码
import time

def boil_water():
    print("开始煮水...")
    time.sleep(5)  # 模拟阻塞等待5秒
    print("水开了!")

def send_message():
    print("开始发短信...")
    time.sleep(2)  # 模拟阻塞等待2秒
    print("短信发送成功!")

def main():
    boil_water()   # 先花5秒煮水,期间什么也不能做
    send_message() # 水开后再花2秒发短信

main()

异步方式:我们请出 asyncio、协程和事件循环。

先看三种并发模型的区别:

模型 原理 特点
多进程 利用多核 CPU 同时执行多个任务 各自独立内存,进程间通信麻烦
多线程 进程内多个子任务,等待时切换 调度由操作系统控制,需锁机制保护共享资源
协程 用户态"轻量级线程",调度由程序员/语言控制 上下文切换开销低,适合 I/O 密集、低计算量场景

协程 是一个特殊的函数 ,它可以在执行过程中暂停 ,并在稍后恢复 执行。它使用 async def 定义,并在需要暂停的地方使用 await

python 复制代码
import asyncio


# 定义协程
async def boil_water_async():
   print("开始煮⽔...")
   await asyncio.sleep(5)  # 关键! await 表⽰"等待这个操作完成,但期间让事件循环去做别的事"
   print("⽔开了!")


async def send_message_async():
   print("开始发短信...")
   await asyncio.sleep(2)  # 同样,等待2秒,但让出控制权
   print("短信发送成功!")


# 主程序(也是⼀个协程)
async def main():
   # 创建两个任务,并交给事件循环去调度
   task1 = asyncio.create_task(boil_water_async())
   task2 = asyncio.create_task(send_message_async())
   # 等待两个任务都完成
   await task1
   await task2

# 它负责创建事件循环,并将第⼀个协程(主程序)放⼊其中运⾏。
asyncio.run(main())

总耗时:5 秒 (因为两个任务的等待时间是并发的)。

通过使用 asyncio ,我们可以在单线程 中同时处理多个任务。一个在单线程内调度和管理 所有协程的核心机制,就是事件循环------它不停地检查哪些协程可以执行,哪些在等待。

概念 说明
协程 特殊的函数,可暂停/恢复 ,用 async def 定义、await 暂停
asyncio.run() 创建事件循环,运行指定的协程
事件循环 核心组件,调度和执行协程,检查任务状态并回调
3.3 astream() 异步流式传输

可以使用 .astream() 方法来异步生成 流式响应的效果,这专为非阻塞工作流而设计:

python 复制代码
import os
import asyncio
from langchain_openai import ChatOpenAI

model = ChatOpenAI(
    model="deepseek-chat",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
)

# astream = 异步版 stream;async for = 异步版 for
# 好处:等待网络时不阻塞程序,可以同时处理多个请求(适合并发场景)
async def async_stream():
    print("=== 异步调用 ===")
    async for chunk in model.astream("讲一个50字的笑话"):
        print(chunk.content, end="", flush=True)
    print()

loop = asyncio.new_event_loop()          # 新建事件循环
loop.run_until_complete(async_stream())  # 在循环里运行异步函数
loop.close()                             # 用完关闭循环
3.4 自定义流式输出解析器

上面我们演示了如何让聊天模型 进行流式输出 。若此时我们希望修改上一步的输出样式 (例如从逐字输出改为逐句输出 ),同时保留流式处理 功能,那么我们需要在 中使用生成器函数 ,即可完成自定义流式输出的能力。

还记得之前说过,聊天模型的 .stream() 方法返回的是一个迭代器 。那么我们实现的这些生成器 的签名应该是:IteratorInput -> IteratorOutput ;对于异步生成器 ,则为:AsyncIteratorInput -> AsyncIteratorOutput

python 复制代码
import os
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from typing import Iterator, List

model = ChatOpenAI(
    model="deepseek-chat",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
)

parser = StrOutputParser()  # 把模型的 AIMessage 转成纯字符串

# 自定义生成器:把流式输出按句号切成句子
# 输入:上一环节(parser)流过来的字符串块;输出:每攒够一个句子就 yield 出去
def split_into_list(input: Iterator[str]) -> Iterator[List[str]]:
    buffer = ""                       # 攒字缓冲区
    for chunk in input:
        buffer += chunk
        while "。" in buffer:         # 缓冲里出现句号 = 攒够一个完整句子
            stop_index = buffer.index("。")
            yield [buffer[:stop_index].strip()]  # 句号前的部分 = 一个句子,产出!
            buffer = buffer[stop_index + 1:]
    if buffer.strip():                # 最后剩下的尾巴(可能没以句号结尾)
        yield [buffer.strip()]

# 链:模型 → 转字符串 → 按句号切句子
chain = model | parser | split_into_list

for chunk in chain.stream("写一份关于爱情的歌词,需要5句话,每句话用句号分割"):
    print(chunk, end="|", flush=True) # 每个 chunk 是一个 [句子] 列表
3.5 深度探索流式传输:SSE 协议

HTTP 协议本身设计为无状态 的请求-响应模式,严格来说,无法做到服务器主动推送消息到客户端 。但通过 Server-Sent Events(服务器发送事件,简称 SSE) 技术可实现流式传输 ,允许服务器主动向浏览器推送数据流------服务器向客户端声明接下来要发送的是流消息(streaming),此时客户端不会关闭连接,会一直等待服务器发送新的数据流。

核心特点:

特点 说明
基于 HTTP 协议 复用标准 HTTP/HTTPS,无需额外端口,兼容性好
单向通信机制 仅支持服务器 → 客户端的单向推送,客户端无法经同一连接回发
自动重连机制 连接中断时浏览器自动重连 (支持 retry 字段指定间隔)
自定义消息类型 响应头设置 Content-Type: text/event-stream,持续推送事件流

数据格式:

服务端向浏览器发送 SSE 数据,需要设置必要的 HTTP 头信息:

python 复制代码
Content-Type: text/event-stream;charset=utf-8
Connection: keep-alive

每一次发送的消息由若干个 message 组成,每个 message 之间由 \n\n 分隔。每个 message 内部由若干行组成:

python 复制代码
[Field]: value\n


event: foo
data: a foo event

data: an unnamed event

event: end
data: a bar event

Field 取值:

Field 说明
data(必需) 数据内容
event(非必需) 自定义事件类型 ,默认是 message 事件
id(非必需) 数据标识符,相当于每条数据的编号
retry(非必需) 指定浏览器重新发起连接的时间间隔

除此之外,还可以有冒号(:) 开头的行,表示注释

LangChain 流式传输流程分析 :LangChain 本身并不"创造"或"规定"一个底层的网络传输协议 ,而是依赖于其底层的大模型供应商 (如 OpenAI)和我们自身服务应用所使用的 Web 框架 的协议。因此,对于 LangChain 的流式传输 能力,本身是因为大模型供应商提供了流式传输能力,由 LangChain 进行调用后接收并处理成一个个的 AIMessageChunk 。当我们发起请求时,会在请求中设置 stream=True_stream() 源码中的第一步),表示服务器将在生成响应时向客户端发送数据(SSE),此时 API 会保持 HTTP 连接打开,并以特定格式发送数据。


四、核心组件:消息与历史管理

4.1 消息(Messages)

消息是聊天模型中的通信单位 ,用于表示聊天模型的输入输出 ,以及可能与对话关联的任何其他上下文或元数据

LLM 消息的三个组成部分:

组成 说明
消息角色(Role) 区分对话中不同类型的消息,帮助模型了解如何响应
消息内容(Content) 文本或多模态数据(图像/音频/视频),大多数模型以文本为主
其他元数据(Additional metadata) 因模型而异的额外信息(时间戳、模型特定参数等)

消息角色详解

4.2 LangChain 消息

LangChain 提供了一种统一的消息格式 ,可以跨聊天模型 使用,允许用户使用不同的聊天模型,而无需担心每个模型提供商使用的消息格式的具体细节:

python 复制代码
# 不同厂商的模型,输入输出统一使用 LangChain 消息格式(模型名请以各厂商最新为准)
openai_model = init_chat_model("gpt-5.1", model_provider="openai")
deepseek_model = init_chat_model("deepseek-chat", model_provider="deepseek")

这些模型提供商 不同,但对于其输入输出 ,统一使用 LangChain消息格式 。它们都是 LangChain BaseMessage子类 ,全部作为 LangChain 聊天模型的输入输出

消息类型 角色
BaseMessage 抽象基类,所有消息的父类
SystemMessage 系统角色,设定对话上下文(通常第一条)
HumanMessage 用户角色,用户输入
AIMessage AI 角色,模型响应(可含工具调用)
ToolMessage 工具角色,工具执行结果

BaseMessage 参数:

参数 说明
content 消息的字符串内容
additional_kwargs 其他有效载荷数据,AI 消息可能含工具调用
response_metadata 响应元数据(响应头、令牌计数、模型名称等)
type 消息类型,用于反序列化时识别
name 消息的人类可读名称(可选)
id 消息的可选唯一标识符

BaseMessage 方法:

方法 说明
pretty_print() 打印消息的漂亮表示
pretty_repr(html=False) 获取消息的漂亮表示(可选 HTML 格式化)
text() 获取消息的文本内容

4.3 缓存历史消息(多轮对话)

我们常常能体验到与大型语言模型 进行连贯的多轮对话的便利性。但目前我们的系统还不支持此功能:

python 复制代码
import os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

model = ChatOpenAI(
    model="deepseek-chat",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
)

# 第一次对话
result = model.invoke([HumanMessage(content="你好,我叫小明")])
result.pretty_print()
# 第二次对话
result = model.invoke([HumanMessage(content="我叫什么名字?")])
result.pretty_print()
python 复制代码
================================== Ai Message ==================================
你好,小明!很高兴认识你!😊
================================== Ai Message ==================================
你好!我并不知道你的名字哦~ 😊

可以发现,聊天模型 并不认识我们,更别说支持更多轮的对话了。稍作修改,让我们将 AI 回复 给我们的响应,跟着新的用户消息 一起发给聊天模型试试:

python 复制代码
import os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage


model = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)

# 记录消息:把完整对话历史(含 AI 上次的回复)都列出来,模型才能"记得"
messages = [
HumanMessage(content="你好,我叫小明"),
AIMessage(content="你好小明!有什么可以帮你的吗?"),
HumanMessage(content="我叫什么名字?"),
]
model.invoke(messages).pretty_print()
python 复制代码
你刚才告诉我你叫**小明**呀!如果之后想改称呼或补充其他信息,随时告诉我哦~ 😄

从结果可知,只要将历史消息,重新发送给聊天模型,那么就可以实现多轮对话的功能。

4.4 管理历史消息
上下文窗口

管理历史消息 ,无非就是理解如何"管理","管理"也就是一些 CRUD(Create、Read、Update、Delete) 操作。在了解如何管理消息之前,需要先了解多轮对话的核心概念:上下文窗口 。上下文窗口可以理解为模型的"短期工作记忆区 ",即 LLM 在一次处理请求时,所能查看和处理的最大 Token 数量,它包含了:

  • 用户的输入

  • 大模型的输出

  • 有时还包括系统指令(SystemMessage)对话历史

不同大模型支持的上下文窗口大小不同。

Token

自然语言处理(NLP) 中,Token 是文本的基本单位 。它不是完全等同于一个单词或一个汉字,而是一个更细粒度的划分计算机无法直接理解文字 ,它需要将文本转换为数字(向量)。Tokenization(令牌化) 就是这个转换过程的第一步 ,将句子分解成模型可以理解和处理的碎片

语言 Token 估算
英文 1 个 Token ≈ 4 个字符 / 0.75 个单词;1000 Tokens ≈ 750 个英文单词
中文 1 个汉字 ≈ 1.5~2 个 Tokens;1000 Tokens ≈ 500~700 个汉字

举个例子上下文窗口 就像一个固定大小的工作台 ,Token 好比积木零件 ,大模型好比工匠 。工匠需要拼出作品,必须把所需的零件(输入的 Token) 放在工作台上,一边拼装(生成回复),一边把拼好的部分(输出的 Token) 也放在工作台上。整个过程(输入 + 输出)中,工作台上的所有积木(Tokens) 总数都不能超过工作台的最大容量(上下文窗口大小) 。如果最初的零件(输入) 太多,占满了工作台,工匠就没有空间进行拼装了,这时你就需要减少零件(精简输入)

消息裁剪(trim_messages)

有了上下文窗口Token 的认知,再来看多轮对话的实现原理,其实就是:

  • 输入 = 系统消息 + 对话历史 + 最新用户问题

  • 对于模型来说,并不真正"记忆",而是每次都将完整的上下文重新输入

由于所有模型的上下文窗口大小 都是有限的,这意味着作为输入的 Token 数量也是有限的。如果累积了很长的消息历史记录,则需要管理 传递给模型的消息的长度trim_messages (LangChain 提供)可用于将聊天历史记录的大小 减小为指定的令牌计数 或指定的消息数量

基于输入 Token 数的修剪:

python 复制代码
import os
from langchain_openai import ChatOpenAI
from langchain_core.messages import (
    HumanMessage, SystemMessage, AIMessage, trim_messages,
)

model = ChatOpenAI(
    model="deepseek-chat",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
)

# 历史消息记录(一长串对话,含"你好,我叫小明"......"我叫什么名字?")
messages = [
    SystemMessage(content="你是一个好助手"),
    HumanMessage(content="你好,我叫小明"),
    # ......(多轮对话历史)......
    HumanMessage(content="我叫什么名字?"),
]

# 自定义 token 计数函数
# 原因:token_counter 直接传 model 会调用 get_num_tokens_from_messages(),
# 该方法对 deepseek-chat 未实现(报 NotImplementedError),
# 所以改为逐条用 get_num_tokens()(DeepSeek 支持)再求和
def token_counter(msgs):
    return sum(model.get_num_tokens(m.content) for m in msgs)

# 使用 trim_messages 减少发送给模型的消息数量
trimmer = trim_messages(
    max_tokens=65,          # 修剪消息的最大令牌数
    strategy="last",        # 修剪策略:"last"(默认)从后往前;"first" 从前往后
    token_counter=token_counter,  # 用自定义函数数 token
    include_system=True,    # 始终保留初始系统消息
    allow_partial=False,    # 是否允许拆分消息的内容
    start_on="human",       # 确保第一条非系统消息始终是 human
)

chain = trimmer | model
print(chain.invoke(messages))
python 复制代码
content='很抱歉,我不知道您的名字。'

可以发现一开始介绍"我叫什么"的消息被删减了,因此 AI 无法识别。 修剪聊天记录后,生成的聊天记录(输入)应该有效 ,但需遵循对话模式原则

修剪后的对话必须遵循的原则:

原则 实现方式
以 HumanMessage 或 SystemMessage 开头,且后跟 HumanMessage 设置 start_on="human"
以 HumanMessage 或 ToolMessage 结尾 设置 ends_on=("human", "tool")
ToolMessage 必须出现在涉及工具调用的 AIMessage 之后 ---
若原历史存在 SystemMessage,新历史必须包含 设置 include_system=True

基于消息数的修剪

除了基于 Token 的修剪,还可以通过设置 token_counter=len根据消息数量 修剪聊天记录。在这种情况下,max_tokens 将控制最大消息数(即保留最近 N 条消息)。

消息过滤(filter_messages)

在更复杂场景 下,我们可能使用消息列表 来跟踪状态,例如只将完整消息列表的子集 传递给模型调用,而非全部历史记录filter_messages 方法可以轻松地按类型ID名称过滤消息:

python 复制代码
from langchain_core.messages import filter_messages

def filter_messages(
    messages: Iterable[MessageLikeRepresentation],
    include_types: Optional[Sequence[Union[str, type[BaseMessage]]]] = None,  # 要包含的消息类型
    exclude_types: Optional[Sequence[Union[str, type[BaseMessage]]]] = None,  # 要排除的消息类型
    include_names: Optional[Sequence[str]] = None,                            # 要包含的消息名称
    exclude_names: Optional[Sequence[str]] = None,                            # 要排除的消息名称
    **kwargs                                                                  # 其他可选参数(如 include_ids、exclude_ids 等)
) -> List[BaseMessage]:

# 功能:
# 根据消息类型(如 system、human、ai)或消息名称过滤消息列表。
# 常用于从对话历史中筛选出特定角色的消息,以精准控制模型输入。

# 参数说明:
# include_types: 要包含的消息类型,可为字符串('system','human','ai')或 BaseMessage 子类。
# exclude_types: 要排除的消息类型。
# include_names: 要包含的消息名称(如 "example_user")。
# exclude_names: 要排除的消息名称。
# **kwargs: 其他可选参数(include_ids、exclude_ids、exclude_tool_calls 等),用法类似。

# 返回值:
# List[BaseMessage]:过滤后的消息列表。
python 复制代码
from langchain_core.messages import (
    HumanMessage, SystemMessage, AIMessage, filter_messages,
)

# 历史消息记录(带 id,方便按 id 筛选)
messages = [
    SystemMessage("你是一个聊天助手", id="1"),
    HumanMessage("示例输入", id="2"),
    AIMessage("示例输出", id="3"),
    HumanMessage("真实输入", id="4"),
    AIMessage("真实输出", id="5"),
]

# 按类型进行筛选:只保留 human(HumanMessage) 类型
print(filter_messages(messages, include_types="human"))
# 结果:[HumanMessage(content='示例输入', ..., id='2'), HumanMessage(content='真实输入', ..., id='4')]

# 按类型+ID 进行筛选:保留 human 和 AI(AIMessage),但排除 id="3"
print(filter_messages(messages, include_types=[HumanMessage, AIMessage], exclude_ids=["3"]))
# 结果:[HumanMessage(content='示例输入', ..., id='2'), HumanMessage(content='真实输入', ..., id='4'),
#        AIMessage(content='真实输出', ..., id='5')]
消息合并(merge_message_runs)

消息列表 中存在连续相同类型 的消息,但某些模型不支持 传递相同类型的连续消息。对于这种情况,可以使用 merge_message_runs 方法轻松合并相同类型的连续消息:

python 复制代码
rom langchain_core.messages import merge_message_runs, BaseMessage

def merge_message_runs(
    messages: Iterable[BaseMessage],                 # 待合并的消息序列
    chunk_separator: str = "\n",                     # 合并时连接消息内容的间隔符,默认换行
) -> List[BaseMessage]:

# 功能:
# 将连续的消息合并为一条消息。如果连续的多个消息具有相同的类型(如 HumanMessage、AIMessage 等),
# 则将其内容连接在一起,形成一条合并后的消息,以简化对话历史并减少 API 调用开销。
# 常见场景:将用户多轮连续输入合并为一个 HumanMessage,或将模型连续输出合并为一个 AIMessage。

# 参数说明:
# messages: 待合并的消息对象列表。
# chunk_separator: 合并时用于连接多条消息内容的间隔字符串,默认为换行符 "\n"。

# 返回值:
# List[BaseMessage]:合并后的消息列表(相邻同类型消息被合并)。
python 复制代码
import os
from langchain_openai import ChatOpenAI
from langchain_core.messages import (
    HumanMessage, SystemMessage, AIMessage, merge_message_runs,
)

model = ChatOpenAI(
    model="deepseek-chat",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
)

# 历史消息记录(存在连续的同类型消息,会被合并)
messages = [
    SystemMessage("你是一个聊天助手。"),
    SystemMessage("你总是以笑话回应。"),
    HumanMessage("为什么要使用 LangChain?"),
    HumanMessage("为什么要使用 LangGraph?"),
    AIMessage("因为当你试图让你的代码更有条理时,LangGraph 会让你感到"节点"是个好主意!"),
    AIMessage("不过别担心,它不会"分散"你的注意力!"),
    HumanMessage("选择LangChain还是LangGraph?"),
]

# 方式一:直接调用函数合并(不建链)
merged = merge_message_runs(messages)   # 把连续同类型消息合并(结果存在 merged 里)
model.invoke(messages).pretty_print()   # 直接调模型(合并与否,模型回答一样)

# 方式二:把合并器接进链里(Runnable 写法)
merger = merge_message_runs()           # 不传 messages,先构造合并器(可复用,合并"连续相邻"的相同类型消息)
chain = merger | model                  # 链:先合并消息 → 再调模型
chain.invoke(messages).pretty_print()   # 效果和方式一等价,但可以继续往后接其他环节

五、复盘(附答案)

💡 思考题

  1. with_structured_output 是什么?它支持哪三种输出结构?用 DeepSeek 时必须指定哪个 method,为什么?

  2. include_raw 和 strict 参数各有什么作用?

  3. invoke 和 stream 有什么区别?AIMessageChunk 是什么?

  4. 同步、多进程、多线程、协程有什么区别?为什么说协程适合 I/O 密集场景?

  5. 为什么聊天模型"记不住"历史?多轮对话的实现原理是什么?trim_messages / filter_messages / merge_message_runs 各解决什么问题?

📝 答案

  1. with_structured_output 是聊天模型的方法:传入输出结构 ,返回一个执行后输出"对象/字典"而非字符串的 Runnable。三种结构:Pydantic (返回对象)、TypedDict (返回字典)、JSON Schema (返回字典)。用 DeepSeek 必须指定 method="function_calling",因为默认的 json_schema 方式 DeepSeek 不支持(会 400 报错)。

  2. include_raw=True :返回含 raw(原始 BaseMessage)、parsed(解析结果)、parsing_error(解析异常)三个键的字典,解析出错也被捕获而不抛异常;strict=True:保证模型输出与 schema 完全匹配,输入 schema 也会被验证。

  3. invoke 等模型全部生成完,一次性返回全量内容(像收快递);stream 逐块返回,每块是一个 AIMessageChunk(AIMessage 的一部分/消息块),可实时显示或用 for 循环收集拼接。AIMessageChunk 支持相加合并成完整消息。

  4. 同步 :一件事做完再做下一件,等待时阻塞;多进程 :多核并行,进程间通信麻烦;多线程 :进程内多任务,OS 调度,需锁保护共享资源;协程 :用户态"轻量级线程",程序员/语言控制切换,上下文切换开销低 ,I/O 密集(如网络请求)时用 async def + await 让事件循环去调度其他任务,适合并发等待场景。

  5. 聊天模型本身不记忆 ------每次调用只看到当次输入。多轮对话的原理是把完整历史(含 AI 历史回复)重新发送给模型 。三个工具:trim_messages 把过长历史裁剪到指定 Token 数/消息数(防止超出上下文窗口);filter_messages 按类型/ID/名称过滤出需要的消息子集;merge_message_runs 合并连续的同类型消息(有些模型不支持连续同类型消息)。


🎯 闭幕

从"结构化输出"到"流式传输",从"消息格式"到"历史管理"------这一篇帮你把 LangChain 的进阶用法走了一遍。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注作者 | 💬 留言交流你的疑问或补充

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

相关推荐
武子康1 小时前
从声学信号到工具阻断:实时语音安全决策门的系统设计
人工智能·llm·agent
醍醐实验室1 小时前
推理链中的 Token 冗余与剪枝:消除无意义语气词对注意力权重的稀释
人工智能
the局外人1 小时前
学习 FastAPI 的 Day 1:看懂接口与请求流程
后端·python·fastapi
额鹅恶饿呃1 小时前
随着CentOS官方停服的时间越来越久,大量仍在使用CentOS7的企业和运维从业者
java·python·算法·c#·ruby
Csvn1 小时前
🐍 Day 11: 调试与诊断 — 从 print 到 pdb 的进阶之路
后端·python
梦想的颜色1 小时前
2026 年 9 月主流 AI 视频生成模型横向硬核测评:价格、能力定位、质量、Agent 工作流适配
人工智能·aigc·大模型测评·ai视频大模型·minimax h3·seedance 2.5
AI 思录1 小时前
Prompt 事故档案(五):AI 道歉,还是“道德漂白”?
人工智能·安全·prompt·用户体验·ai安全·ai幻觉
我是你的开心果7781 小时前
ai全栈应用开发学习day19
学习
公爵爱学习2 小时前
无人机多点导航笔记
java·前端·笔记