主题
消息协议
要点
- 对 Agent 来说,真正的输入单位不是单个字符串,而是带角色的消息数组。
- 消息数组的价值在于把上下文拆清楚:系统规则、用户输入、模型回复、工具结果各有位置。
- 前期只需要理解四种角色:
system、user、assistant、tool。 systemPrompt是 Agent 的长期设定,messages是当前请求的上下文。两者职责分开,代码结构更清晰。- 多轮对话和工具调用之所以依赖消息协议,是因为它们需要把中间状态按顺序回传给模型。
1. 背景:从字符串到消息数组
上一篇里,最小 Agent 的调用代码大致如下:
typescript
const stream = await agent.stream(
{
messages: [
{
role: "user",
content: "请用一句话说明当前是 Agent 流式调用验证。",
},
],
},
{
streamMode: "messages",
},
);这里最值得注意的地方不是 stream(),而是 messages。传给 Agent 的不是一段字符串,而是一组消息。这说明在 LangChain 里,到了 Agent 这一层,真正重要的输入单位已经不是「一句话」,而是「一组带角色的上下文」。
2. 为什么 Agent 需要消息数组
如果只是一次最简单的生成,模型当然可以直接吃字符串:
typescript
const response = await model.invoke("帮我生成一段测试日志的示例");但只要开始做 Agent,很快就会遇到这些内容:
- 系统设定
- 用户这一轮输入
- 模型上一轮说过的话
- 工具执行结果
- 多轮对话历史
如果把这些内容全都揉成一段长字符串,代码也能跑,但会有两个明显问题:
第一,角色容易混。
你很难稳定区分哪些是系统规则,哪些是用户输入,哪些又是模型自己上一轮说的话。字符串没有结构,只能靠分隔符或约定格式,既不鲁棒又难维护。
第二,上下文难维护。
一旦接上工具、记忆、历史消息,这段大字符串会越来越长,也越来越难改。任何一个新角色加入,都需要重新调整拼接逻辑。
所以 LangChain 会把这些内容都整理成消息。从 Agent 的角度看,这样更自然:
- Agent 接收的是一组消息。
- Agent 根据消息判断下一步怎么做。
- Agent 再把新的消息加回这条上下文里。
3. 四种核心角色
这一阶段不需要把所有消息类名都背下来,先把下面四种角色理解透就够了:
| 角色 | 谁发出的 | 作用 |
|---|---|---|
| system | 开发者 / 系统 | 给 Agent 立规则、定身份 |
| user | 用户 | 表达这一轮的问题或要求 |
| assistant | 模型 / Agent | 表示 Agent 自己给出的回复 |
| tool | 工具执行层 | 把工具运行结果回给 Agent |
可以用更直白的方式记它们:
system:告诉 Agent 你是谁、该遵守什么规则。user:告诉 Agent 用户现在要什么。assistant:告诉 Agent 你刚刚说过什么。tool:告诉 Agent 工具刚刚做完了什么。
tool 角色在前面三种里用得最少,但一讲工具调用,它就变得必不可少。
4. 对象字面量与消息类
在 LangChain 里,消息有两种常见写法:
- 直接写对象字面量。
- 显式使用消息类。
当前阶段建议先用对象字面量,因为它最短、最直观。直接调模型时可以这样写:
typescript
const response = await model.invoke([
{
role: "system",
content: "你是一名说话简洁的技术助手。",
},
{
role: "user",
content: "请解释一下为什么 Agent 要用消息数组。",
},
]);
console.log(response.text);Agent 调用时也类似:
typescript
const stream = await agent.stream(
{
messages: [
{
role: "user",
content: "解释一下消息协议。",
},
],
},
{
streamMode: "messages",
},
);这两段代码都在做同一件事:把输入整理成消息,再交给模型或 Agent。
如果后面开始显式操作消息对象,也可以这样导入:
typescript
import { HumanMessage, SystemMessage } from "langchain";
// 或者:import { HumanMessage, SystemMessage } from "@langchain/core/messages"消息类和对象字面量的功能等价,只是前者提供了更多类型安全和便利方法。在简单场景里,对象字面量足够。
5. systemPrompt 与 messages 的职责边界
把消息放回 Agent 运行过程里,会更清楚。一个最小 Agent 通常这样定义:
typescript
import { createAgent } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
apiKey: process.env.MODEL_API_KEY,
model: process.env.MODEL_NAME ?? "deepseek-chat",
configuration: {
baseURL: process.env.MODEL_BASE_URL ?? "https://api.deepseek.com/v1",
},
});
const agent = createAgent({
model,
tools: [],
systemPrompt: "你是一名说话自然、简短的技术助手。",
});这里的 systemPrompt 是 Agent 的默认设定。真正运行时,当前这一轮的输入再通过 messages 传进去:
typescript
const stream = await agent.stream(
{
messages: [
{
role: "user",
content: "解释一下消息协议。",
},
],
},
{
streamMode: "messages",
},
);所以这一层的关系可以先这样理解:
systemPrompt:Agent 自带的长期设定,每次请求都会带上。messages:当前这轮请求带进来的上下文,动态变化。
前期写代码时,一个稳的做法是:
- 人设、规则、语气要求,放进
systemPrompt。 - 用户输入、多轮上下文、工具结果,放进
messages。
这样代码结构最清楚,也方便后续把 systemPrompt 抽成配置、把 messages 交给记忆模块管理。
6. 多轮对话与工具调用为什么离不开消息
单轮调用时,消息协议的价值还不算特别明显。一旦进入多轮对话,它就立刻变得很重要。
比如下面这组消息:
typescript
const messages = [
{
role: "system",
content: "你是一名专业、简洁的技术助手。",
},
{
role: "user",
content: "我今天被需求折腾得很烦。",
},
{
role: "assistant",
content: "先别急,和我说说最卡的是哪一段。",
},
{
role: "user",
content: "明明昨天定好的方案,今天又全改了。",
},
];如果没有中间这条 assistant 消息,模型看到的只是两条用户输入。带上它之后,Agent 才能知道刚才回复过什么,从而接着往下说。消息的顺序和角色共同决定了对话的连续性。
工具调用也是一样。当 Agent 判断要调用工具时,流程通常会变成:
- 用户发来请求。
- Agent 判断要不要调工具。
- 工具执行。
- 工具结果回到消息流里。
- Agent 继续生成最终回复。
工具结果并不是「额外塞回模型」的一段文本,它也是消息流的一部分,通常以 tool 角色出现。因此,消息协议是工具调用和多轮对话能够稳定工作的基础。
如果你在旧资料里看到 FunctionMessage,可以把它当成早期 function calling 语义里的历史概念。当前这条主线里,更值得优先理解的是 system、user、assistant、tool。
7. 总结
消息协议是 LangChain Agent 的基础约定。先记住三件事:
- 对 Agent 来说,真正的输入单位是
messages,不是单个字符串。 - 前期最常用的四种角色是
system、user、assistant、tool。 - 消息协议不是为了让写法更复杂,而是为了把上下文分清楚,让 Agent 能稳定继续工作。
接下来要考虑的问题是:既然输入已经是结构化消息了,那 Prompt 模板是怎么把这些消息稳定组织起来的呢?