第4章 消息系统与多模态
本书章节导航
- 前言
- 第1章 为什么需要理解 LangChain
- 第2章 架构总览
- 第3章 Runnable 与 LCEL 表达式语言
- 第4章 消息系统与多模态 (当前)
- 第5章 语言模型抽象层
- 第6章 提示词模板引擎
- 第7章 输出解析与结构化输出
- 第8章 工具系统
- 第9章 文档加载与文本分割
- 第10章 向量存储与检索器
- 第11章 Chain 组合模式
- 第12章 回调与可观测性
- 第13章 记忆与会话管理
- 第14章 Agent 架构与执行循环
- 第15章 工具调用与 Agent 模式
- 第16章 序列化与配置系统
- 第17章 Partner 集成架构
- 第18章 设计模式与架构决策
本章基于 LangChain 1.0.3 / langchain-core 1.2.26 源码分析。核心代码位于
langchain_core/messages/目录。
消息(Message)是 LangChain 中最基础、最核心的数据结构之一。在与大语言模型交互的过程中,无论是用户向模型提问、模型生成回复、系统预设角色行为,还是工具返回执行结果,所有这些信息都以消息的形式在系统中流转。可以说,消息就是 LangChain 与大语言模型对话的通用"货币"。
从架构角度来看,LangChain 的消息系统承担着三重职责。首先是抽象统一:不同的模型提供商(OpenAI、Anthropic、Google、AWS Bedrock 等)各自定义了不同的消息格式,LangChain 需要用统一的数据模型屏蔽这些差异,让应用代码与具体提供商解耦。其次是多模态表达:随着大模型向多模态方向演进,消息不再仅仅是文本,还可能包含图片、音频、视频、文件等多种模态的数据,消息系统需要为此提供标准化的表示框架。最后是流式支持:现代大模型应用普遍采用流式输出来提升用户体验,消息系统需要支持片段(Chunk)级别的增量传输与合并。
LangChain 1.0 版本对消息系统进行了重大升级,引入了 Content Block 体系和标准化的多模态内容表示。本章将从 BaseMessage 基类出发,逐层剖析消息系统的类型层级、流式消息的合并机制、多模态 Content Block 体系以及丰富的消息工具函数,深入理解这套精心设计的数据基础设施。
::: tip 本章要点
- BaseMessage 层级体系:
BaseMessage及其子类HumanMessage、AIMessage、SystemMessage、ToolMessage如何构成完整的消息类型系统 - MessageChunk 流式消息:
BaseMessageChunk通过__add__运算符实现流式片段的增量合并 - Content Blocks 多模态体系:1.0 版本引入的标准化内容块(Text/Image/Audio/Video/File/Reasoning),以及
content_blocks属性如何统一不同提供商的格式 - ToolCall 与工具调用:
ToolCall、ToolCallChunk、InvalidToolCall三级工具调用结构 - 消息工具函数:
convert_to_messages、filter_messages、trim_messages等实用函数的设计与实现 :::
4.1 BaseMessage:消息体系的基石
LangChain 的整个消息系统构建在 BaseMessage 这个基类之上。它继承自 Serializable,这意味着所有消息对象都天然具备序列化和反序列化能力。这一设计决策使得消息对象可以被持久化到数据库、通过网络传输到远程服务,或者存储到 LangSmith 等追踪平台,而无需额外的序列化适配代码。
从设计哲学角度看,BaseMessage 采用了"宽进严出"的原则:它允许 content 字段接受字符串或列表两种格式,给予上游代码最大的灵活性;但通过 content_blocks 属性提供了统一的标准化视图,让下游消费者可以用一致的方式处理各种格式的内容。这种双层设计在框架领域是一种经典的"适配器模式"应用。
4.1.1 核心字段设计
# langchain_core/messages/base.py
class BaseMessage(Serializable):
content: str | list[str | dict]
"""消息内容:纯字符串或混合内容块列表"""
additional_kwargs: dict = Field(default_factory=dict)
"""附加数据,例如模型提供商的原始工具调用信息"""
response_metadata: dict = Field(default_factory=dict)
"""响应元数据:响应头、logprobs、token 计数、模型名称等"""
type: str
"""消息类型标识符,用于序列化/反序列化时的类型辨别"""
name: str | None = None
"""可选的消息名称,提供人类可读的标识"""
id: str | None = Field(default=None, coerce_numbers_to_str=True)
"""可选的唯一标识符,通常由提供商或模型生成"""
content 字段的联合类型设计 str | list[str | dict] 是一个精妙的决策。当消息只包含纯文本时,它是一个简单的字符串,这是绝大多数场景下的使用方式。当消息包含多模态内容(如图片、音频等)时,它变成一个列表,列表中的每个元素可以是纯文本字符串或描述特定内容块的字典。这种设计既保证了简单场景的易用性(不需要将纯文本包装成列表),又为复杂的多模态场景提供了足够的表达力。值得注意的是,Pydantic 的 ConfigDict(extra="allow") 配置允许消息对象携带任意额外字段,这为子类扩展和提供商特定属性留出了空间。
additional_kwargs 字段扮演着"后备箱"的角色,用来存放那些尚未被标准化的提供商特定数据。例如,在 LangChain 0.x 版本中,OpenAI 的工具调用信息是通过 additional_kwargs["tool_calls"] 传递的。随着框架的演进,这些数据逐渐被迁移到了专门的标准化字段(如 AIMessage.tool_calls),但 additional_kwargs 仍然为新出现的、尚未被标准化的数据提供了灵活的承载空间。这种"先兼容后标准化"的演进策略贯穿了 LangChain 消息系统的整个发展历程。
response_metadata 字段与 additional_kwargs 的定位不同。它专门存储与模型响应相关的元数据,例如 HTTP 响应头、logprobs(对数概率)、token 计数详情和模型名称等。将响应元数据与消息内容分离存储的好处是:序列化消息时可以选择性地包含或排除这些元数据,而且不会干扰消息内容的正常处理流程。
id 字段使用了 Pydantic 的 coerce_numbers_to_str=True 选项,这意味着即使传入数字类型的 ID,也会被自动转换为字符串。这种防御性设计处理了某些提供商可能返回数字 ID 的情况。
4.1.2 content_blocks 属性:多模态统一接口
1.0 版本引入的 content_blocks 属性是消息系统最重要的新增设计之一。它将 content 字段中的原始数据统一转换为标准化的 ContentBlock 类型列表:
@property
def content_blocks(self) -> list[types.ContentBlock]:
blocks: list[types.ContentBlock] = []
content = (
[self.content] if isinstance(self.content, str) and self.content
else self.content
)
for item in content:
if isinstance(item, str):
blocks.append({"type": "text", "text": item})
elif isinstance(item, dict):
item_type = item.get("type")
if item_type not in types.KNOWN_BLOCK_TYPES:
blocks.append({"type": "non_standard", "value": item})
else:
blocks.append(cast("types.ContentBlock", item))
# 多轮解析:尝试将 non_standard 块转为标准块
for parsing_step in [
_convert_v0_multimodal_input_to_v1,
_convert_to_v1_from_chat_completions_input,
_convert_to_v1_from_anthropic_input,
_convert_to_v1_from_genai_input,
_convert_to_v1_from_converse_input,
]:
blocks = parsing_step(blocks)
return blocks
这个设计的巧妙之处在于它的"渐进式解析"策略。整个转换过程分为两个阶段:第一阶段是快速分类,遍历所有内容项,将已知类型(通过 KNOWN_BLOCK_TYPES 集合判断)直接映射为标准块,将未知类型标记为 non_standard。第二阶段是多轮转换,依次尝试五个提供商格式转换器(v0 旧格式、OpenAI Chat Completions、Anthropic、Google GenAI、Bedrock Converse),每个转换器只处理 non_standard 类型的块,跳过已经标准化的块。
这种"先分类后转换"的两阶段设计在性能和兼容性之间取得了很好的平衡:对于已经是标准格式的内容块,第一阶段就完成了处理,不需要经历任何转换器的尝试;对于提供商特定格式的内容块,它们会按照优先级依次被各个转换器尝试转换,最终要么被成功标准化,要么保持为 non_standard 交由应用层自行处理。
还有一个值得注意的细节:代码中对 source_type 字段进行了特殊检查。在 LangChain v0 时代,多模态块使用了 source_type 字段来区分数据来源(如 "base64"、"url" 等),而 v1 的标准块不使用这个字段。因此,即使一个 v0 块的 type 恰好与 v1 的已知类型名称相同(如 "image"),只要它包含 source_type 字段,就会被标记为 non_standard 交给专门的 v0 转换器处理,避免了格式混淆。
4.1.3 text 属性与向后兼容
@property
def text(self) -> TextAccessor:
if isinstance(self.content, str):
text_value = self.content
else:
blocks = [
block for block in self.content
if isinstance(block, str)
or (block.get("type") == "text" and isinstance(block.get("text"), str))
]
text_value = "".join(
block if isinstance(block, str) else block["text"] for block in blocks
)
return TextAccessor(text_value)
TextAccessor 是一个继承自 str 的特殊类型,它既支持属性访问 message.text(推荐方式),又支持方法调用 message.text()(旧方式,会发出弃用警告)。这是一种典型的平滑迁移策略:在 LangChain 0.x 版本中,text 是一个方法,需要通过 message.text() 调用;在 1.0 版本中改为了更直观的属性访问方式 message.text。TextAccessor 通过继承 str 并实现 __call__ 方法,使得新旧两种访问方式都能正常工作,给了开发者充足的迁移窗口。
text 属性的提取逻辑也很精细:当 content 是字符串时直接返回;当 content 是列表时,它只提取纯字符串元素和 type 为 "text" 的字典块中的文本,然后将它们拼接在一起。这意味着图片块、音频块等非文本内容会被自动忽略,调用者始终能得到一个干净的纯文本表示。
classDiagram
class Serializable {
+is_lc_serializable() bool
+get_lc_namespace() list
}
class BaseMessage {
+content: str | list
+additional_kwargs: dict
+response_metadata: dict
+type: str
+name: str | None
+id: str | None
+content_blocks: list~ContentBlock~
+text: TextAccessor
+pretty_repr(html) str
+pretty_print() void
}
class HumanMessage {
+type: "human"
}
class AIMessage {
+type: "ai"
+tool_calls: list~ToolCall~
+invalid_tool_calls: list~InvalidToolCall~
+usage_metadata: UsageMetadata | None
}
class SystemMessage {
+type: "system"
}
class ToolMessage {
+type: "tool"
+tool_call_id: str
+artifact: Any
+status: "success" | "error"
}
Serializable <|-- BaseMessage
BaseMessage <|-- HumanMessage
BaseMessage <|-- AIMessage
BaseMessage <|-- SystemMessage
BaseMessage <|-- ToolMessage
4.2 消息类型全景
4.2.1 HumanMessage:用户消息
了解了 BaseMessage 的核心设计之后,让我们逐一审视它的四个主要子类。每种子类都针对特定的交互角色进行了精确的字段定制。
HumanMessage 是所有消息类型中最简单的一个。它代表用户发送给模型的消息,是对话交互的起点。它的定义几乎就是对 BaseMessage 的最小扩展:
# langchain_core/messages/human.py
class HumanMessage(BaseMessage):
type: Literal["human"] = "human"
它的 type 字段使用 Literal["human"] 类型注解硬编码为 "human",默认值也是 "human"。这个看似简单的设计其实蕴含着 Pydantic 的区分联合类型(Discriminated Union)机制:当需要从 JSON 数据反序列化消息时,系统可以根据 type 字段的值精确地选择对应的消息类。
虽然 HumanMessage 的类定义很简洁,但它继承了 BaseMessage 的全部能力,包括通过 content_blocks 传入多模态内容的能力。以下是一些典型用法:
from langchain_core.messages import HumanMessage
# 纯文本
msg = HumanMessage(content="什么是 LangChain?")
# 多模态:文本 + 图片
msg = HumanMessage(content_blocks=[
{"type": "text", "text": "请描述这张图片"},
{"type": "image", "url": "https://example.com/photo.png"},
])
4.2.2 AIMessage:模型响应消息
AIMessage 是消息体系中最复杂的类型。作为模型的输出载体,它不仅承载文本响应,还需要处理工具调用请求、token 使用统计、推理思考过程等丰富的结构化元数据。可以说,AIMessage 的复杂度直接反映了现代大语言模型输出的丰富度。
# langchain_core/messages/ai.py
class AIMessage(BaseMessage):
tool_calls: list[ToolCall] = Field(default_factory=list)
"""标准化的工具调用列表"""
invalid_tool_calls: list[InvalidToolCall] = Field(default_factory=list)
"""解析失败的工具调用列表"""
usage_metadata: UsageMetadata | None = None
"""token 使用统计"""
type: Literal["ai"] = "ai"
AIMessage 的 _backwards_compat_tool_calls 模型验证器(使用 @model_validator(mode="before") 装饰器)是消息系统中最重要的兼容性代码之一。它在 Pydantic 模型初始化之前运行,确保无论工具调用数据是以新格式(直接在 tool_calls 字段中)还是旧格式(在 additional_kwargs["tool_calls"] 中以 OpenAI 原始格式存储)存在,最终都能被正确解析到标准化的 tool_calls 和 invalid_tool_calls 字段中。这段代码的存在保证了从 LangChain 0.x 迁移到 1.0 的应用不会因为工具调用格式的变化而崩溃:
@model_validator(mode="before")
@classmethod
def _backwards_compat_tool_calls(cls, values: dict) -> Any:
check_additional_kwargs = not any(
values.get(k) for k in ("tool_calls", "invalid_tool_calls", "tool_call_chunks")
)
if check_additional_kwargs and (
raw_tool_calls := values.get("additional_kwargs", {}).get("tool_calls")
):
parsed_tool_calls, parsed_invalid_tool_calls = default_tool_parser(
raw_tool_calls
)
values["tool_calls"] = parsed_tool_calls
values["invalid_tool_calls"] = parsed_invalid_tool_calls
return values
UsageMetadata:token 使用统计
class UsageMetadata(TypedDict):
input_tokens: int
output_tokens: int
total_tokens: int
input_token_details: NotRequired[InputTokenDetails]
output_token_details: NotRequired[OutputTokenDetails]
UsageMetadata 提供了跨模型的标准化 token 计数表示。InputTokenDetails 进一步细分了缓存命中 (cache_read)、缓存创建 (cache_creation) 和音频 token (audio)。OutputTokenDetails 则区分了推理 token (reasoning) 和音频输出 token。这种细粒度的分类为精确的成本核算提供了基础。
4.2.3 SystemMessage:系统指令
# langchain_core/messages/system.py
class SystemMessage(BaseMessage):
type: Literal["system"] = "system"
SystemMessage 用于设定模型的行为规则和角色设定,通常作为消息序列的第一条消息出现。系统消息不参与对话的"来回",而是作为一种"场外指令"影响模型在整个对话过程中的行为。例如,告诉模型"你是一个专业的法律顾问"或"请用中文回答所有问题"。
从 LangChain 1.0 开始,convert_to_messages 函数同时支持 "system" 和 "developer" 两种角色名来创建 SystemMessage。"developer" 是 OpenAI 在其较新的 API 中引入的角色类型,其语义与系统消息基本相同,但在某些模型中会有微妙的行为差异。LangChain 将 "developer" 角色映射为 SystemMessage,同时在 additional_kwargs 中记录 __openai_role__: "developer" 以便在转换回 OpenAI 格式时保留原始角色信息。这种处理方式体现了 LangChain 对"信息无损传递"原则的坚持。
4.2.4 ToolMessage:工具响应
# langchain_core/messages/tool.py
class ToolMessage(BaseMessage, ToolOutputMixin):
tool_call_id: str
"""关联的工具调用 ID"""
type: Literal["tool"] = "tool"
artifact: Any = None
"""工具执行产物,不发送给模型"""
status: Literal["success", "error"] = "success"
"""工具调用状态"""
ToolMessage 是消息系统中连接模型与外部世界的桥梁。当模型发出工具调用请求(通过 AIMessage.tool_calls)后,应用代码执行实际的工具调用,然后将结果包装为 ToolMessage 反馈给模型。ToolMessage 同时继承了 BaseMessage 和 ToolOutputMixin,后者是一个标记性的空 mixin 类。
ToolMessage 的设计有几个值得深入探讨的要点:
-
tool_call_id 关联机制:通过
tool_call_id将工具响应与AIMessage中的ToolCall一一对应,这在并行工具调用场景下至关重要。 -
artifact 字段:这是一个巧妙的"二通道"设计。
content存放发送给模型的工具结果摘要,而artifact存放完整的工具输出(如图表、原始数据等)。这样既控制了发送给模型的信息量,又保留了完整数据供其他处理环节使用。 -
ToolOutputMixin:这是一个空的 mixin 类,用作类型标记。如果自定义工具的输出是
ToolOutputMixin的实例,LangChain 会直接返回它;否则,会将输出强制转换为字符串并包装为ToolMessage。
sequenceDiagram
participant User as 用户
participant Model as 语言模型
participant Tool as 外部工具
User->>Model: HumanMessage("今天北京天气如何?")
Model->>User: AIMessage(tool_calls=[{name:"get_weather", args:{city:"北京"}, id:"call_123"}])
User->>Tool: 调用 get_weather(city="北京")
Tool->>User: {"temp": 25, "weather": "晴"}
User->>Model: ToolMessage(content="25度,晴", tool_call_id="call_123")
Model->>User: AIMessage("北京今天天气晴朗,气温25度。")
4.3 MessageChunk:流式消息的增量合并
流式输出是现代大语言模型应用的标配能力。用户不愿意等待模型生成完整回复后才看到结果,而是希望看到文字逐步"流淌"出来。LangChain 通过 BaseMessageChunk 及其子类来表示流式传输中的消息片段。Chunk 的核心设计理念是:每个片段都是一个"增量",它可以与前面的片段通过 + 运算符合并,最终得到一个完整的消息。这种基于运算符重载的合并方式使得流式消息的累积处理变得极其自然。
4.3.1 BaseMessageChunk 的合并机制
# langchain_core/messages/base.py
class BaseMessageChunk(BaseMessage):
def __add__(self, other: Any) -> BaseMessageChunk:
if isinstance(other, BaseMessageChunk):
return self.__class__(
id=self.id,
type=self.type,
content=merge_content(self.content, other.content),
additional_kwargs=merge_dicts(
self.additional_kwargs, other.additional_kwargs
),
response_metadata=merge_dicts(
self.response_metadata, other.response_metadata
),
)
...
BaseMessageChunk 通过重载 __add__ 运算符实现了消息片段的增量合并。两个 Chunk 相加时,它们的 content、additional_kwargs 和 response_metadata 都会被分别合并。内容合并的核心依赖 merge_content 函数,该函数根据参与合并的数据类型采取不同的策略:
def merge_content(first_content, *contents):
merged = "" if first_content is None else first_content
for content in contents:
if isinstance(merged, str):
if isinstance(content, str):
merged += content # str + str = str
else:
merged = [merged, *content] # str + list = list
elif isinstance(content, list):
merged = merge_lists(merged, content) # list + list = merge by index
elif merged and isinstance(merged[-1], str):
merged[-1] += content # 追加到最后一个字符串元素
...
return merged
merge_lists 是一个基于 index 键的列表合并函数,它是整个流式合并机制中最精妙的组件。其工作原理是:如果两个内容块有相同的 index 值,它们会被合并在一起(字典类型的块会递归合并,字符串类型的块会拼接);如果没有匹配的 index,新块会被追加到列表末尾。这种基于索引的合并语义对于流式工具调用场景至关重要:当模型并行调用多个工具时,每个工具调用的 JSON 参数会以多个 Chunk 的形式分批到达,index 字段确保属于同一个工具调用的片段被正确合并在一起。
4.3.2 AIMessageChunk 的特殊处理
AIMessageChunk 是所有 Chunk 类型中最复杂的,它通过多重继承同时获得了 AIMessage 的工具调用能力和 BaseMessageChunk 的合并能力。除了基本的内容合并之外,它还需要处理流式工具调用的逐步解析:
# langchain_core/messages/ai.py
class AIMessageChunk(AIMessage, BaseMessageChunk):
type: Literal["AIMessageChunk"] = "AIMessageChunk"
tool_call_chunks: list[ToolCallChunk] = Field(default_factory=list)
chunk_position: Literal["last"] | None = None
tool_call_chunks 字段专门用于存储流式传输中的工具调用片段。与完整的 ToolCall(其 args 是已解析的 Python 字典)不同,ToolCallChunk 的 args 是 JSON 字符串的原始片段。例如,一个工具调用可能先收到 args='{"a":',再收到 args='1}'。当多个 Chunk 通过 __add__ 合并时,这些 JSON 片段会按 index 分组拼接。拼接后的字符串会在 init_tool_calls 模型验证器中通过 parse_partial_json 进行解析尝试。如果解析成功,工具调用会被放入 tool_calls 列表;如果解析失败(通常因为 JSON 尚不完整),会被放入 invalid_tool_calls 列表。
chunk_position 字段标记当前 Chunk 在流中的位置。当值为 "last" 时,表示这是流的最终片段。此时,init_tool_calls 验证器会执行一项额外操作:将 content 列表中的 tool_call_chunk 类型块替换为完整的 tool_call 类型块。这确保了当消费者将所有 Chunk 合并后,得到的最终消息与非流式调用产生的消息在结构上完全一致。
graph LR
subgraph 流式输入
C1["Chunk 1<br/>content='Hello'"]
C2["Chunk 2<br/>content=' World'"]
C3["Chunk 3<br/>tool_call_chunks=..."]
C4["Chunk 4<br/>tool_call_chunks=..."]
C5["Chunk 5<br/>chunk_position='last'"]
end
subgraph 合并结果
R["AIMessageChunk<br/>content='Hello World'<br/>tool_calls=[{name:'foo', args:{a:1}}]"]
end
C1 --> |"__add__"| C2
C2 --> |"__add__"| C3
C3 --> |"__add__"| C4
C4 --> |"__add__"| C5
C5 --> R
4.3.3 add_ai_message_chunks 函数
AIMessageChunk.__add__ 实际委托给 add_ai_message_chunks 函数,该函数执行一系列精细的合并操作:
def add_ai_message_chunks(left: AIMessageChunk, *others: AIMessageChunk) -> AIMessageChunk:
content = merge_content(left.content, *(o.content for o in others))
additional_kwargs = merge_dicts(left.additional_kwargs, *(o.additional_kwargs for o in others))
response_metadata = merge_dicts(left.response_metadata, *(o.response_metadata for o in others))
# 合并 tool_call_chunks
if raw_tool_calls := merge_lists(
left.tool_call_chunks, *(o.tool_call_chunks for o in others)
):
tool_call_chunks = [create_tool_call_chunk(...) for rtc in raw_tool_calls]
else:
tool_call_chunks = []
# Token 使用量累加
if left.usage_metadata or any(o.usage_metadata is not None for o in others):
usage_metadata = left.usage_metadata
for other in others:
usage_metadata = add_usage(usage_metadata, other.usage_metadata)
# ID 选择策略:提供商 ID > lc_run ID > 其他 ID
...
return left.__class__(content=content, ...)
合并过程中 ID 的选择策略值得注意:优先使用提供商分配的 ID(非 lc_ 前缀),其次是 LangChain 生成的运行 ID(lc_run-* 前缀),最后是其他 ID。这保证了在流式合并后,最终消息的 ID 尽可能有意义。
4.4 Content Blocks:多模态内容的标准化表示
4.4.1 设计理念
Content Block 体系是 LangChain 1.0 版本最具变革性的设计之一。在此之前,多模态内容的表示方式是碎片化的:OpenAI 使用 image_url 块,Anthropic 使用 source 块,Google 使用 inline_data 块。应用代码如果需要在不同提供商之间切换,就必须适配这些不同的格式。Content Block 体系引入了一个统一的、提供商无关的中间格式,彻底解决了这个问题。content.py 模块的文档开篇就阐明了这一设计理念:
不同的 LLM 提供商使用截然不同且互不兼容的 API schema。本模块提供了一个统一的、提供商无关的格式来促进这些交互。发送给或来自模型的消息就是一个内容块列表,允许文本、图片和其他内容在一个有序序列中自然交织。
4.4.2 内容块类型一览
graph TB
CB["ContentBlock<br/>(联合类型)"]
subgraph 文本类
T["TextContentBlock<br/>type='text'"]
R["ReasoningContentBlock<br/>type='reasoning'"]
end
subgraph 工具类
TC["ToolCall<br/>type='tool_call'"]
TCC["ToolCallChunk<br/>type='tool_call_chunk'"]
ITC["InvalidToolCall<br/>type='invalid_tool_call'"]
STC["ServerToolCall<br/>type='server_tool_call'"]
STR["ServerToolResult<br/>type='server_tool_result'"]
end
subgraph 多媒体类
IMG["ImageContentBlock<br/>type='image'"]
VID["VideoContentBlock<br/>type='video'"]
AUD["AudioContentBlock<br/>type='audio'"]
PT["PlainTextContentBlock<br/>type='text-plain'"]
F["FileContentBlock<br/>type='file'"]
end
subgraph 扩展类
NS["NonStandardContentBlock<br/>type='non_standard'"]
end
CB --> T
CB --> R
CB --> TC
CB --> TCC
CB --> ITC
CB --> STC
CB --> STR
CB --> IMG
CB --> VID
CB --> AUD
CB --> PT
CB --> F
CB --> NS
4.4.3 多媒体内容块的统一结构
所有多媒体数据块(Image/Video/Audio/PlainText/File)共享一组标准字段:
| 字段 | 类型 | 说明 |
|---|---|---|
type | Literal | 块类型标识 |
id | NotRequired[str] | 唯一标识符 |
file_id | NotRequired[str] | 外部文件存储系统的引用 |
mime_type | NotRequired[str] | MIME 类型 |
url | NotRequired[str] | 数据 URL |
base64 | NotRequired[str] | Base64 编码数据 |
extras | NotRequired[dict] | 提供商特定元数据 |
这种统一的结构设计使得不同类型的多媒体数据可以用相同的方式处理。数据可以通过三种途径提供:url 指定远程资源地址、base64 内联编码数据、file_id 引用外部文件存储系统中的文件(如 OpenAI 或 Anthropic 的 Files API)。extras 字段为提供商特定的元数据提供了标准化的存放位置,例如 Google 的思维签名(thought signature)或 Anthropic 的缓存控制指令。
这种设计的一个重要优点是它允许渐进式扩展。当未来出现新的多媒体类型(如 3D 模型或表格数据)时,只需定义新的 TypedDict 类型并添加到 ContentBlock 联合类型中,现有代码完全不受影响。源码中有一段注释明确列出了未来可能添加的模态类型,包括 3D 模型和表格数据。
# ImageContentBlock 示例
class ImageContentBlock(TypedDict):
type: Literal["image"]
id: NotRequired[str]
file_id: NotRequired[str]
mime_type: NotRequired[str]
url: NotRequired[str]
base64: NotRequired[str]
extras: NotRequired[dict[str, Any]]
4.4.4 TextContentBlock 与 Annotation
文本内容块不仅包含文本本身,还支持引用注解(Citation),这对于 RAG(检索增强生成)应用尤为重要:
class TextContentBlock(TypedDict):
type: Literal["text"]
text: str
annotations: NotRequired[list[Annotation]]
extras: NotRequired[dict[str, Any]]
class Citation(TypedDict):
type: Literal["citation"]
url: NotRequired[str]
title: NotRequired[str]
start_index: NotRequired[int]
end_index: NotRequired[int]
cited_text: NotRequired[str]
Citation 中的 start_index 和 end_index 指向的是响应文本中的位置(不是源文档),而 cited_text 则包含被引用的源文本片段。源码中有一段注释解释了为什么不包含源文档的文本偏移量:跨不同文件格式和编码方案可靠地提取源文档中的精确范围(span)是非常困难的,而 cited_text 字段对于大多数使用场景已经足够。这种务实的设计选择避免了过度工程化,同时保留了引用功能的核心价值。
4.4.5 ReasoningContentBlock:推理内容
随着推理模型(如 OpenAI o1 系列、DeepSeek R1、Claude 3.5 Sonnet 的扩展思考等)的兴起,模型的输出不再仅仅是最终答案,还可能包含中间的推理过程。ReasoningContentBlock 应运而生,用于标准化表示模型的推理/思考内容:
class ReasoningContentBlock(TypedDict):
type: Literal["reasoning"]
reasoning: NotRequired[str]
extras: NotRequired[dict[str, Any]]
AIMessage 的 content_blocks 属性会执行最佳努力的推理内容提取。不同的模型提供商将推理内容放在不同的位置:Anthropic 直接放在内容块列表中;而 Ollama、DeepSeek、XAI、Groq 等则将其存储在 additional_kwargs["reasoning_content"] 中。LangChain 通过 _extract_reasoning_from_additional_kwargs 辅助函数统一处理后者的情况,将推理内容提取出来并作为 ReasoningContentBlock 插入到内容块列表的开头。选择插入到开头是因为推理过程在逻辑上先于最终回复发生,将其放在列表首位更符合时间顺序。同时,代码会检查是否已经存在推理块,避免重复添加。
4.4.6 提供商格式转换管线
content_blocks 属性实现了一个精巧的多阶段转换管线:
flowchart LR
A["原始 content<br/>(提供商格式)"] --> B["第一阶段<br/>已知类型直接映射"]
B --> C["标记 non_standard"]
C --> D1["v0 旧格式转换"]
D1 --> D2["OpenAI 格式转换"]
D2 --> D3["Anthropic 格式转换"]
D3 --> D4["Google GenAI 格式转换"]
D4 --> D5["Bedrock Converse 格式转换"]
D5 --> E["标准化<br/>ContentBlock 列表"]
每个转换步骤只处理 non_standard 类型的块,跳过已经标准化的块。这意味着一旦一个块被某个转换器成功处理,后续的转换器就不会再碰它,保证了转换效率和结果的确定性。
对于 AIMessage,content_blocks 属性还有一条快速路径(fast path):如果 response_metadata 中包含 model_provider 信息,会优先使用对应提供商的专用转换器,直接进行精确转换,完全跳过通用的多轮猜测流程。此外,如果 response_metadata["output_version"] 为 "v1",说明内容已经是标准化格式,content_blocks 属性会直接返回 content 列表的类型转换结果,零开销地完成转换。这种多级缓存和快速路径的设计体现了对热路径性能的重视。
4.5 ToolCall 与工具调用体系
4.5.1 三级工具调用结构
LangChain 的工具调用体系包含三种密切相关但用途不同的类型:
# 完整的工具调用(非流式)
class ToolCall(TypedDict):
name: str # 工具名称
args: dict[str, Any] # 已解析的参数字典
id: str | None # 关联 ID
type: NotRequired[Literal["tool_call"]]
# 流式工具调用片段
class ToolCallChunk(TypedDict):
name: str | None # 可能为 None(部分传输)
args: str | None # JSON 字符串片段
id: str | None
index: int | None # 在序列中的索引,用于合并
type: NotRequired[Literal["tool_call_chunk"]]
# 解析失败的工具调用
class InvalidToolCall(TypedDict):
name: str | None
args: str | None # 原始字符串,非字典
id: str | None
error: str | None # 错误描述
type: NotRequired[Literal["invalid_tool_call"]]
ToolCallChunk 的 index 字段在流式合并中起到关键作用。考虑这样一个场景:模型同时调用了 get_weather 和 get_news 两个工具,流式传输中的 Chunk 可能交替到达。index=0 标识第一个工具调用的片段,index=1 标识第二个工具调用的片段。merge_lists 函数会按照 index 值将属于同一工具调用的 Chunk 合并在一起,确保每个工具调用的参数 JSON 被正确拼接。
还需要注意的是,ToolCall 和 ToolCallChunk 在 content.py 和 tool.py 中各有一份定义。tool.py 中的是旧版定义(用于 AIMessage.tool_calls 等字段),content.py 中的是新版定义(作为标准内容块类型)。两者的结构基本相同,但新版多了 extras 和 index 字段。这种看似冗余的双重定义是版本演进过程中保持向后兼容的结果。
4.5.2 default_tool_parser:向后兼容的解析器
当工具调用数据以 OpenAI 格式存储在 additional_kwargs["tool_calls"] 中时,default_tool_parser 负责将其解析为标准格式:
def default_tool_parser(raw_tool_calls: list[dict]) -> tuple[list[ToolCall], list[InvalidToolCall]]:
tool_calls = []
invalid_tool_calls = []
for raw_tool_call in raw_tool_calls:
if "function" not in raw_tool_call:
continue
function_name = raw_tool_call["function"]["name"]
try:
function_args = json.loads(raw_tool_call["function"]["arguments"])
tool_calls.append(tool_call(name=function_name, args=function_args, id=raw_tool_call.get("id")))
except json.JSONDecodeError:
invalid_tool_calls.append(invalid_tool_call(
name=function_name,
args=raw_tool_call["function"]["arguments"],
id=raw_tool_call.get("id"),
))
return tool_calls, invalid_tool_calls
解析失败不会抛出异常,而是将失败的调用放入 invalid_tool_calls 列表。这种"宽容解析"的设计具有重要的实践价值:在生产环境中,模型有时会生成格式不完全正确的 JSON(例如多余的逗号或缺少引号),如果对此直接抛出异常,可能导致整个对话流程中断。通过将解析失败的调用收集而非丢弃,应用层可以自行决定处理策略——重试、修复、忽略或提示用户。同时,其他格式正确的工具调用不会受到影响,保证了系统的可用性。
4.5.3 ServerToolCall 与服务端工具调用
1.0 版本引入了 ServerToolCall 和 ServerToolResult 来表示在服务端执行的工具调用(如代码执行、网络搜索等):
class ServerToolCall(TypedDict):
type: Literal["server_tool_call"]
id: str
name: str
args: dict[str, Any]
class ServerToolResult(TypedDict):
type: Literal["server_tool_result"]
tool_call_id: str
status: Literal["success", "error"]
output: NotRequired[Any]
与客户端的 ToolCall 不同,ServerToolCall 的 id 字段是必需的(不允许 None),因为服务端工具的调用和结果之间的关联是由服务端保证的。
4.6 消息工具函数
4.6.1 convert_to_messages:万能消息转换器
def convert_to_messages(
messages: Iterable[MessageLikeRepresentation] | PromptValue,
) -> list[BaseMessage]:
if isinstance(messages, PromptValue):
return messages.to_messages()
return [_convert_to_message(m) for m in messages]
convert_to_messages 是消息系统中使用频率最高的工具函数。它在 LangChain 的许多核心路径上被调用:BaseChatModel._convert_input 中用它将用户输入转换为消息列表,ChatPromptTemplate.format_messages 中用它处理 MessagesPlaceholder 的值,各种消息操作工具函数中也都以它作为入口。它能将多种格式的输入统一转换为 BaseMessage 列表。核心的 _convert_to_message 函数内部支持以下输入格式:
BaseMessage实例 -- 直接返回- 2-元组
(role, content)-- 例如("human", "Hello") - 字典
{"role": "...", "content": "..."}-- OpenAI 格式 - 纯字符串 -- 视为
HumanMessage
角色映射支持多种别名:"human" 和 "user" 都映射到 HumanMessage,"ai" 和 "assistant" 都映射到 AIMessage,"system" 和 "developer" 都映射到 SystemMessage。
4.6.2 filter_messages:消息过滤器
filter_messages 提供了基于名称、类型和 ID 的多维度消息过滤能力:
@_runnable_support
def filter_messages(
messages,
*,
include_names=None, # 保留指定名称
exclude_names=None, # 排除指定名称
include_types=None, # 保留指定类型
exclude_types=None, # 排除指定类型
include_ids=None, # 保留指定 ID
exclude_ids=None, # 排除指定 ID
exclude_tool_calls=None # 排除工具调用
) -> list[BaseMessage]:
@_runnable_support 装饰器是消息工具函数的一项巧妙设计。它赋予了函数"双面身份":当传入消息列表时,函数直接执行过滤操作并返回结果(即作为普通函数使用);当不传入消息参数(messages=None)时,函数返回一个 RunnableLambda 对象,可以作为 LCEL 链中的一个环节参与管道组合。这种设计让同一个函数同时满足了直接调用和链式组合两种使用模式,大大提高了 API 的灵活性:
# 直接调用
filtered = filter_messages(messages, include_types=["human", "ai"])
# 作为 Runnable 使用
chain = filter_messages(include_types=["human", "ai"]) | model
exclude_tool_calls 参数支持两种模式:True 表示排除所有工具调用相关消息;传入具体的 tool_call_id 列表则只排除对应的工具调用。
4.6.3 trim_messages:消息裁剪器
trim_messages 是对话历史管理中不可或缺的工具,也是目前 LangChain 消息工具函数中参数最丰富、功能最强大的一个。在长对话场景中,消息历史可能超出模型的上下文窗口限制,需要智能地裁剪。简单的截断会破坏消息序列的有效性(例如在 ToolMessage 之前截断,导致缺少对应的 AIMessage 工具调用),因此 trim_messages 提供了一系列参数来确保裁剪后的消息序列仍然是有效的模型输入:
@_runnable_support
def trim_messages(
messages,
*,
max_tokens: int,
token_counter: Callable | BaseLanguageModel | Literal["approximate"],
strategy: Literal["first", "last"] = "last",
allow_partial: bool = False,
end_on: str | type | Sequence | None = None,
start_on: str | type | Sequence | None = None,
include_system: bool = False,
text_splitter: Callable | None = None,
) -> list[BaseMessage]:
几个关键参数的设计思路:
- strategy="last":保留最新的消息,丢弃旧消息,这是对话场景最常见的需求
- include_system=True:即使裁剪掉了大量旧消息,系统消息仍然保留在开头
- start_on="human":确保裁剪后的消息序列以
HumanMessage开头,满足大多数模型的输入要求 - token_counter="approximate":使用近似 token 计数器
count_tokens_approximately,在热路径上避免精确 token 计数的开销
# 典型用法:保留系统消息,从 HumanMessage 开始,最多 4096 token
trimmed = trim_messages(
messages,
max_tokens=4096,
strategy="last",
token_counter="approximate",
start_on="human",
include_system=True,
)
4.6.4 其他消息工具
除了上述三个核心函数外,消息工具模块还提供了一系列实用的辅助函数:
-
get_buffer_string:将消息列表转换为格式化字符串,支持"prefix"和"xml"两种格式。"prefix"格式使用传统的"Role: content"格式,简单但在内容包含类似前缀时可能产生歧义。"xml"格式使用<message type="role">标签包裹内容,并通过xml.sax.saxutils.escape和quoteattr自动转义特殊字符,能够可靠地处理任何文本内容。XML 格式还支持多模态内容块的格式化,但 base64 编码的数据会被自动跳过(因为它们对于字符串表示没有可读性价值)。 -
message_chunk_to_message:将MessageChunk转换为对应的普通Message。其实现巧妙地利用了 Python 的 MRO(方法解析顺序)-- Chunk 类的第一个父类就是对应的非 Chunk 类:
def message_chunk_to_message(chunk: BaseMessage) -> BaseMessage:
if not isinstance(chunk, BaseMessageChunk):
return chunk
ignore_keys = ["type"]
if isinstance(chunk, AIMessageChunk):
ignore_keys.extend(["tool_call_chunks", "chunk_position"])
return chunk.__class__.__mro__[1](
**{k: v for k, v in chunk.__dict__.items() if k not in ignore_keys}
)
AnyMessage:使用 Pydantic 的Discriminator和Tag机制定义的联合类型,支持基于type字段的自动反序列化:
AnyMessage = Annotated[
Annotated[AIMessage, Tag(tag="ai")]
| Annotated[HumanMessage, Tag(tag="human")]
| Annotated[SystemMessage, Tag(tag="system")]
| Annotated[ToolMessage, Tag(tag="tool")]
| ...,
Field(discriminator=Discriminator(_get_type)),
]
4.7 设计决策分析
4.7.1 TypedDict vs Pydantic Model
Content Block 使用 TypedDict 而非 Pydantic Model,这是一个经过深思熟虑的选择:
-
性能:TypedDict 是纯类型注解,运行时就是普通字典,没有 Pydantic 验证的开销。在高频流式场景下,每秒可能产生数十个内容块,性能差异显著。
-
互操作性:普通字典与 JSON 序列化/反序列化天然兼容,不需要额外的转换步骤。
-
灵活性:TypedDict 允许
NotRequired字段,使得内容块可以只包含必要的字段。同时extras字段为提供商特定数据提供了标准化的扩展点。
4.7.2 content 与 content_blocks 的双轨制
content 字段存储原始的提供商格式数据,而 content_blocks 属性提供懒加载的标准化视图。这种双轨设计的好处是:
-
零成本抽象:如果应用不需要标准化视图(例如只处理纯文本),content_blocks 的解析开销完全不会产生。
-
无损存储:原始数据保留在
content中,序列化/反序列化不会丢失信息。 -
渐进迁移:通过
output_version="v1"配置,可以让模型输出直接存储标准化格式,为未来的全面迁移铺路。
4.7.3 宽容解析策略
整个消息系统贯穿了一种"宽容解析"的设计哲学:
- 工具调用解析失败不抛异常,而是放入
invalid_tool_calls - 未知的内容块标记为
non_standard而非报错 coerce_args验证器尝试将非标准输入强制转换为合法格式
这种设计极大地提高了系统的健壮性,使其能够优雅地处理各种预期外的输入,而不是在第一个异常处崩溃。
4.8 小结
LangChain 的消息系统是整个框架的数据基础设施。本章深入分析了以下核心内容:
-
BaseMessage 层级体系:以
Serializable为根,通过type字段辨别的四种核心消息类型(Human/AI/System/Tool),每种类型都针对其使用场景进行了精确的字段设计。 -
流式消息合并:
BaseMessageChunk通过__add__运算符和merge_content/merge_lists函数实现了增量合并,AIMessageChunk进一步处理了流式工具调用的合并和解析。 -
Content Block 多模态体系:1.0 版本引入的标准化内容块系统,通过
content_blocks属性的懒加载转换管线,将各种提供商格式统一为 Text/Image/Audio/Video/File/Reasoning 等标准类型。 -
工具调用三级结构:
ToolCall(完整调用)、ToolCallChunk(流式片段)、InvalidToolCall(解析失败)构成了完整的工具调用生命周期表示。 -
实用工具函数:
convert_to_messages的万能转换能力、filter_messages的多维度过滤、trim_messages的智能裁剪,以及它们通过@_runnable_support获得的 LCEL 集成能力。
消息系统的设计充分体现了"简单场景简单用,复杂场景不受限"的理念,它既是一个轻量的数据传输格式,又是一个可扩展的多模态内容表示框架。