Msg 不是 String:AgentScope Java 的消息模型为什么这么设计?
很多 Java 开发者第一次看 AgentScope Java 的 Msg,心里都会冒出一个疑问。
这玩意是不是有点过度设计了?
用户说一句话而已。
为什么不直接传 String。
为什么非要搞成。
Msg.builder()
.role(MsgRole.USER)
.textContent("你好,介绍一下 AgentScope Java")
.build();
看着就比一个字符串麻烦。
而且你乍一看,好像还真像是把字符串外面包了一层对象。
如果只是聊天,这种怀疑非常自然。
但你只要把第 2 篇刚拆过的 ReActAgent 运行链再想一遍,很快就会发现,String 在这里根本不够用。
因为 Agent 运行里要表达的,从来不只是「说了什么」。
它还要表达。
维度 | Agent 运行里真的要保存什么 |
|---|---|
谁说的 | 用户、助手、系统、工具 |
说了什么 | 文本、图片、音频、视频、thinking、工具调用、工具结果 |
调了什么工具 | 工具名、调用 id、参数 |
工具返回了什么 | 输出内容、是否 suspended、附加 metadata |
这条消息处于什么运行语境 | 结构化输出、usage、generate reason |
后续怎么继续跑 | 这条消息能不能进 Memory,下一轮 reasoning 要不要消费它 |
String 只能承载最中间那一列。
也就是字面文本。
但 Agent Runtime 真正关心的是整张表。
所以这一篇我最想讲清楚的,不是 Msg 这个类有多少字段。
而是它为什么必须存在。
如果只传 String,会先丢掉什么
我们先从最熟悉的情况说起。
在普通聊天程序里,一个字符串通常够用。
String userInput = "帮我查一下北京时间";
这个场景默认你只关心两件事。
用户说了什么。
模型回了什么。
但到了 AgentScope Java,这个假设从第一步就不成立。
因为在 Agent 里,同一句内容放在不同位置,含义完全可能不一样。
比如这些消息。
写出来像文本 | 运行时真实身份 |
|---|---|
| 用户输入 |
| 系统消息 |
| assistant 发出的工具调用意图 |
| 工具执行结果 |
| Agent 的退出状态说明 |
你看,字面上它们都能长得像字符串。
但在运行时,它们不是一回事。
这也是为什么 MsgRole 在 AgentScope Java 里不是可有可无。
源码里就四个角色。
角色 | 含义 |
|---|---|
| 用户或外部输入 |
| AI Agent 生成的消息 |
| 系统指令、提示词、配置消息 |
| 工具执行结果消息 |
如果没有 role,你只能靠约定去猜。
但 Runtime 不可能靠猜来运转。
模型格式化层要知道哪些消息是 system。
Memory 恢复时要知道哪些消息是 tool result。
Hook 要知道当前拦截的是 assistant reasoning,还是工具阶段回填结果。
所以从 Agent Runtime 的角度看,String 最先丢掉的,不是内容。
而是语义身份。

先看三条最小消息到底长什么样
如果只聊概念,Msg 还是容易被看成一个“讲究一点的字符串对象”。
不如直接看三条最小消息。
Msg userMsg =
Msg.builder()
.name("alice")
.role(MsgRole.USER)
.content(TextBlock.builder().text("帮我查一下北京时间").build())
.build();
Msg systemMsg =
Msg.builder()
.name("system")
.role(MsgRole.SYSTEM)
.textContent("You are a helpful assistant.")
.build();
Msg toolResultMsg =
Msg.builder()
.name("tool")
.role(MsgRole.TOOL)
.content(
ToolResultBlock.builder()
.id("call-001")
.output(
TextBlock.builder()
.text(
"Current time in Asia/Shanghai: 2026-06-03 10:00:00")
.build())
.build())
.build();
这三条消息看着都能“还原成一句话”。
但它们在 Runtime 里的职责完全不同。
消息 | 运行作用 |
|---|---|
| 代表外部输入,后面会进入 Memory,作为下一轮推理上下文 |
| 代表系统约束,格式化时会被当成 system message 处理 |
| 代表工具返回,不是普通 assistant 文本,而是下一轮 reasoning 的输入材料 |
也就是说,Msg 的重点从来不是“能不能把一句话装进去”。
而是“Runtime 能不能准确知道这句话是什么性质”。
textContent(...) 只是糖衣,底层还是 ContentBlock
这里有个特别值得拿出来说的细节。
很多人会因为 Msg.builder().textContent("...") 这个 API,误以为 Msg 的本体还是一个文本字段。
其实不是。
Msg 真正存内容的字段叫 content。
类型是。
private final List<ContentBlock> content;
也就是说,Msg 的内容不是一个 String。
而是一组内容块。
甚至连最简单的 textContent(...),本质上也只是一个便利方法。
源码里写得很直白。
public Builder textContent(String text) {
this.content = List.of(TextBlock.builder().text(text).build());
return this;
}
这个细节非常重要。
因为它说明了一个根本设计。
普通文本不是 Msg 的默认真身。
普通文本只是 ContentBlock 体系里最常见的一种。
也就是 TextBlock。
从这个角度你再回头看 Msg,感觉会完全不一样。
它不是「一个字符串对象」。
它是「一条带身份、带内容块、带运行附加信息的消息记录」。
这也是为什么 ContentBlock 在源码里直接被做成了 sealed hierarchy。
支持的类型不是一个两个,而是一整套。
| 作用 |
|---|---|
| 普通文本 |
| 推理 / thinking 内容 |
| 图片 |
| 音频 |
| 视频 |
| 工具调用请求 |
| 工具执行结果 |
这时候你应该已经能感觉到,AgentScope Java 的消息模型从一开始就没打算服务于「只有文字」这一种场景。
它服务的是一条 Agent 运行链。
而这条链里,模型、工具、多模态、structured output,迟早都会进来。
所以与其后面再拼命打补丁,不如一开始就把消息的基本单位设计成结构化内容块。

Msg 真正在保存什么
如果你直接看 Msg 的字段,会发现它其实不复杂。
核心就是这几个。
字段 | 作用 |
|---|---|
| 每条消息的唯一标识 |
| 发送方名字,通常是用户名、agent 名或 tool 名 |
| 消息身份 |
|
|
| 结构化输出、usage、generate reason 等附加信息 |
| 消息时间戳 |
看起来字段不多。
但每一个都不是摆设。
先说 id。
源码里 Msg.builder() 一启动就会生成随机 UUID。
这意味着消息默认就是可追踪的。
不是临时字符串。
再说 name。
这玩意在普通聊天里看着没那么重要。
但在多 Agent、工具结果、用户输入源不止一个的时候,它会立刻变得有用。
测试工具类 TestUtils 里就很典型。
创建用户消息时会写。
Msg.builder()
.name(name)
.role(MsgRole.USER)
.content(TextBlock.builder().text(text).build())
.build();
创建 assistant 消息时,会把 role 改成 ASSISTANT。
创建 tool result message 时,会用 TOOL。
也就是说,name + role 组合起来,已经能表达出一条消息到底是谁、以什么身份发出来的。
然后是 content。
这里才是 Msg 的真正核心。
因为 Runtime 后面所有关键分支,几乎都要靠它判断。
比如。
msg.hasContentBlocks(ToolUseBlock.class)
msg.getContentBlocks(ToolResultBlock.class)
msg.getFirstContentBlock(TextBlock.class)
这套 API 背后的设计思想特别清楚。
AgentScope 不假设消息里只有一种内容。
也不假设每条消息都能被拍扁成纯文本。
它允许 Runtime 按块理解内容。
这件事对后面工具调用尤其重要。

为什么 ToolUseBlock 必须是结构,而不是一句话
到这儿你其实已经能猜到,为什么 Agent 不能把工具调用表示成一句字符串了。
因为工具调用不是描述。
它是指令。
源码里的 ToolUseBlock 核心字段就是这几个。
字段 | 作用 |
|---|---|
| 这次工具调用的唯一 id |
| 要调用哪个工具 |
| 工具参数, |
| 流式工具调用时的原始内容片段 |
| provider-specific 附加信息 |
这里最关键的是 id + name + input。
因为在 Runtime 里,一次工具调用不是一句解释性的自然语言。
它必须能被稳定识别、验证、执行、恢复。
如果你只是让模型输出一段文本。
请调用 get_current_time,参数 timezone=Asia/Shanghai
那后面真正执行时就会一团糟。
你怎么精确校验参数。
怎么知道这是第几个工具调用。
怎么在工具结果回来时一一对应。
怎么做 pending tool recovery。
怎么在 streaming 场景里把半截工具调用片段拼起来。
这些事,String 都做不好。
所以 ToolUseBlock 本质上不是「模型说了一句想调用工具的话」。
它是 Runtime 可执行的工具调用记录。
这一点在第 2 篇已经露过脸。
ReActAgent 判断要不要进入 acting,看的是 assistant 消息里有没有 ToolUseBlock。
不是看文本里有没有类似「我要调用某工具」这种句子。
这也是为什么消息模型一旦结构化,Runtime 的判断就会干净很多。

为什么 ToolResultBlock 也不能只回字符串
工具结果这块更有意思。
很多人会下意识觉得,工具返回字符串不就够了吗。
比如。
return "Current time in Asia/Shanghai: 2026-06-03 10:00:00";
单看工具方法,好像确实可以。
但站在 Runtime 视角,还差一层。
因为工具结果不仅要作为内容返回。
还要作为一条可继续进入 ReAct 循环的消息回到上下文里。
源码里的 ToolResultBlock 也不是简单文本容器。
它至少要表达这些东西。
字段 / 能力 | 作用 |
|---|---|
| 对应哪次 tool call |
| 哪个工具返回的 |
| 结果内容,类型仍然是 |
| 附加执行信息 |
| 这次工具是否进入外部执行挂起状态 |
注意这里一个很细的设计。
ToolResultBlock 的 output 不是 String。
还是 List<ContentBlock>。
也就是说,连工具结果本身都没有被强制限制成纯文本。
这让工具结果后面继续承载更复杂内容成为可能。
另外,ToolResultBlock 还有一套很 Runtime 的语义。
比如 suspended。
当工具抛出 ToolSuspendException 时,框架会构造一个 suspended result。
这时候返回给用户的已经不是简单错误字符串,而是一个带状态的工具结果对象。
这对 HITL、外部执行、恢复继续跑,都是关键能力。
再往前走一步,你会发现框架甚至没有把 ToolResultBlock 直接塞回 Memory。
它会先通过 ToolResultMessageBuilder 包一层真正的消息。
return Msg.builder()
.name(agentName)
.role(MsgRole.TOOL)
.content(resultWithIdAndName)
.build();
这段代码特别值得品一下。
工具结果不是孤零零一个 result object。
而是会被包装成。
一条 role=TOOL 的 Msg
这说明在 AgentScope Java 眼里,工具结果最终也属于消息流的一部分。
不是外挂在外面的回调值。
metadata 不是杂物间,它承载的是运行语义
聊到这里,很多人会对 metadata 还有点警惕。
总觉得这是个「啥都往里塞」的口袋。
但从 AgentScope Java 的实现看,它装的并不是无序杂项。
它装的是那些不适合出现在可见内容里、但又对 Runtime 很重要的语义信息。
最典型的有三类。
能力 | 在 |
|---|---|
结构化输出 |
|
token / 时间 usage |
|
Agent 生成原因 |
|
这个设计其实非常务实。
因为有些信息你当然不希望混进 TextBlock。
比如。
本次消息的 structured output。
本次模型调用的 token usage。
这条消息是正常结束,还是 TOOL_SUSPENDED,还是 MAX_ITERATIONS。
这些都是真实运行信息。
但它们不该被当成用户可见文本拼进正文。
这时候 metadata 就变成了一条消息的第二层语义面。
你可以把它理解成。
content 负责表达“这条消息说了什么”
metadata 负责表达“这条消息在运行里意味着什么”
这也是为什么我一直觉得,Msg 在 AgentScope Java 里更像一个运行时信封。
表面那张纸是内容。
信封外面和夹层里,还带着系统需要继续处理的上下文信息。

这套消息模型给工程接入带来的真实影响
如果你把这篇文章只看成「源码类结构介绍」,那还差最后一步。
真正重要的是,这套设计会直接影响你怎么接工程。
最典型的几点是这些。
工程动作 | 更好的做法 | 不太好的做法 |
|---|---|---|
Controller 接收用户输入 | 尽早转成 | 全链路都只传 |
持久化对话 | 保留 | 只存 |
记录工具调用 | 保留 | 只打平为日志字符串 |
Agent 返回结果 | 同时看 | 只打印答案文本 |
多模态 / structured output 扩展 | 直接复用 | 额外发明一套平行 DTO |
这里最容易踩的坑,是过早把 Msg 拍平成字符串。
因为 getTextContent() 只是一个方便读取文本的视图。
它内部做的事很简单。
只会把 TextBlock 抽出来,再按换行拼起来。
如果一条消息里主要承载的是 ToolUseBlock 或 ToolResultBlock,那你只看 getTextContent(),很多运行信息就直接没了。
所以在企业项目里,Msg 更适合被当成 Agent Runtime 的通用消息 DTO。
而不是一个为了兼容框架才临时出现的壳。
为什么 Msg 不是 String,这回应该真清楚了
如果要把这篇文章压成一句话,我觉得可以这么记。
Msg 不是把文本外面套一层对象,而是把 Agent 运行真正需要的身份、内容结构和运行语义收进同一个消息单元。
你也可以用这个表做最后的对照。
对比项 |
|
|
|---|---|---|
表达纯文本 | 可以 | 可以 |
表达消息身份 | 不行 |
|
表达多模态内容 | 不行 |
|
表达工具调用 | 不稳定 |
|
表达工具结果 | 不稳定 |
|
表达结构化输出 / usage / generate reason | 很别扭 |
|
适合进入 ReAct Runtime | 不够 | 就是为这个设计的 |
所以从第 3 篇开始,你对 AgentScope Java 的理解应该再往前推一格。
前两篇我们拆的是。
Agent 不等于一次模型调用。
ReActAgent.call() 也不是普通方法调用。
这一篇再往下一层,就是。
Agent 运行里的最小沟通单位,也不是字符串。
它必须是结构化消息。
因为只有这样,后面的 Formatter、Tool 调用、Memory、Hook、结构化输出、Session 持久化,才能全都站在同一块地基上。
下一篇我们就接着往下拆。
为什么换模型的时候,Agent 代码可以尽量不动。
继续阅读
基于全文检索与主题相似度
工具描述写不好,Agent 就会乱调用:一次 Tool Schema 实验
事情是这样的。 上一篇我们把 AgentScope Java 的 Tool 拆清楚了。 Tool 不是一个普通 Java 方法。 它至少有两面。 这件事一旦理解了,后面一个问题就会变得很刺眼。 既然模型看到的不是 Java 方法体,而是一份 ToolSchema。 那这份 schema 写得好不好,...
AgentScope Java 的 Tool 到底是什么?它不是简单的 Java 方法
事情是这样的。 前面几篇我们已经把 AgentScope Java 的运行心智拆到一个比较清楚的位置了。 Agent 不是一次模型调用。 ReActAgent.call() 背后是一条执行链。 Msg 不是普通字符串。 Formatter 负责把统一消息结构翻译成不同模型厂商的请求。 第 5 篇又把...
ReAct 不神秘:我用 Java 手写了一个极简 Agent 循环
事情是这样的。 前面几篇我们一直在拆 AgentScope Java 的运行结构。 第 1 篇讲的是,Agent 不是一次模型 API 调用。 第 2 篇讲的是,agent.call(...) 背后不是一跳,而是一整条执行链。 第 3 篇讲的是,Msg 不是 String,因为 Agent 运行里要...