Msg 不是 String:AgentScope Java 的消息模型为什么这么设计?

AgentScope Java 原理与企业级智能体实战34 次阅读23 分钟

很多 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 里,同一句内容放在不同位置,含义完全可能不一样。

比如这些消息。

写出来像文本

运行时真实身份

帮我查一下北京时间

用户输入

You are a helpful assistant

系统消息

调用 get_current_time,参数是 Asia/Shanghai

assistant 发出的工具调用意图

Current time in Asia/Shanghai: ...

工具执行结果

本次调用达到最大迭代次数

Agent 的退出状态说明

你看,字面上它们都能长得像字符串。

但在运行时,它们不是一回事。

这也是为什么 MsgRole 在 AgentScope Java 里不是可有可无。

源码里就四个角色。

角色

含义

USER

用户或外部输入

ASSISTANT

AI Agent 生成的消息

SYSTEM

系统指令、提示词、配置消息

TOOL

工具执行结果消息

如果没有 role,你只能靠约定去猜。

但 Runtime 不可能靠猜来运转。

模型格式化层要知道哪些消息是 system。

Memory 恢复时要知道哪些消息是 tool result。

Hook 要知道当前拦截的是 assistant reasoning,还是工具阶段回填结果。

所以从 Agent Runtime 的角度看,String 最先丢掉的,不是内容。

而是语义身份。

01-role-not-text.png

先看三条最小消息到底长什么样

如果只聊概念,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 里的职责完全不同。

消息

运行作用

userMsg

代表外部输入,后面会进入 Memory,作为下一轮推理上下文

systemMsg

代表系统约束,格式化时会被当成 system message 处理

toolResultMsg

代表工具返回,不是普通 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。

支持的类型不是一个两个,而是一整套。

ContentBlock 子类型

作用

TextBlock

普通文本

ThinkingBlock

推理 / thinking 内容

ImageBlock

图片

AudioBlock

音频

VideoBlock

视频

ToolUseBlock

工具调用请求

ToolResultBlock

工具执行结果

这时候你应该已经能感觉到,AgentScope Java 的消息模型从一开始就没打算服务于「只有文字」这一种场景。

它服务的是一条 Agent 运行链。

而这条链里,模型、工具、多模态、structured output,迟早都会进来。

所以与其后面再拼命打补丁,不如一开始就把消息的基本单位设计成结构化内容块。

02-contentblock-under-shell.png

Msg 真正在保存什么

如果你直接看 Msg 的字段,会发现它其实不复杂。

核心就是这几个。

字段

作用

id

每条消息的唯一标识

name

发送方名字,通常是用户名、agent 名或 tool 名

role

消息身份

content

ContentBlock 列表

metadata

结构化输出、usage、generate reason 等附加信息

timestamp

消息时间戳

看起来字段不多。

但每一个都不是摆设。

先说 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 按块理解内容。

这件事对后面工具调用尤其重要。

03-msg-runtime-envelope.png

为什么 ToolUseBlock 必须是结构,而不是一句话

到这儿你其实已经能猜到,为什么 Agent 不能把工具调用表示成一句字符串了。

因为工具调用不是描述。

它是指令。

源码里的 ToolUseBlock 核心字段就是这几个。

字段

作用

id

这次工具调用的唯一 id

name

要调用哪个工具

input

工具参数,Map<String, Object>

content

流式工具调用时的原始内容片段

metadata

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 的判断就会干净很多。

04-tooluse-structured-call.png

为什么 ToolResultBlock 也不能只回字符串

工具结果这块更有意思。

很多人会下意识觉得,工具返回字符串不就够了吗。

比如。

return "Current time in Asia/Shanghai: 2026-06-03 10:00:00";

单看工具方法,好像确实可以。

但站在 Runtime 视角,还差一层。

因为工具结果不仅要作为内容返回。

还要作为一条可继续进入 ReAct 循环的消息回到上下文里。

源码里的 ToolResultBlock 也不是简单文本容器。

它至少要表达这些东西。

字段 / 能力

作用

id

对应哪次 tool call

name

哪个工具返回的

output

结果内容,类型仍然是 List<ContentBlock>

metadata

附加执行信息

isSuspended()

这次工具是否进入外部执行挂起状态

注意这里一个很细的设计。

ToolResultBlockoutput 不是 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 很重要的语义信息。

最典型的有三类。

能力

Msg 里的体现

结构化输出

hasStructuredData() / getStructuredData(...)

token / 时间 usage

getChatUsage()

Agent 生成原因

getGenerateReason() / withGenerateReason(...)

这个设计其实非常务实。

因为有些信息你当然不希望混进 TextBlock

比如。

本次消息的 structured output。

本次模型调用的 token usage。

这条消息是正常结束,还是 TOOL_SUSPENDED,还是 MAX_ITERATIONS

这些都是真实运行信息。

但它们不该被当成用户可见文本拼进正文。

这时候 metadata 就变成了一条消息的第二层语义面。

你可以把它理解成。

content 负责表达“这条消息说了什么”
metadata 负责表达“这条消息在运行里意味着什么”

这也是为什么我一直觉得,Msg 在 AgentScope Java 里更像一个运行时信封。

表面那张纸是内容。

信封外面和夹层里,还带着系统需要继续处理的上下文信息。

05-toolresult-runtime-semantics.png

这套消息模型给工程接入带来的真实影响

如果你把这篇文章只看成「源码类结构介绍」,那还差最后一步。

真正重要的是,这套设计会直接影响你怎么接工程。

最典型的几点是这些。

工程动作

更好的做法

不太好的做法

Controller 接收用户输入

尽早转成 Msg

全链路都只传 String,最后才临时包一下

持久化对话

保留 role / content / metadata

只存 getTextContent()

记录工具调用

保留 ToolUseBlock / ToolResultBlock 原结构

只打平为日志字符串

Agent 返回结果

同时看 textContentgenerateReason

只打印答案文本

多模态 / structured output 扩展

直接复用 ContentBlock / metadata

额外发明一套平行 DTO

这里最容易踩的坑,是过早把 Msg 拍平成字符串。

因为 getTextContent() 只是一个方便读取文本的视图。

它内部做的事很简单。

只会把 TextBlock 抽出来,再按换行拼起来。

如果一条消息里主要承载的是 ToolUseBlockToolResultBlock,那你只看 getTextContent(),很多运行信息就直接没了。

所以在企业项目里,Msg 更适合被当成 Agent Runtime 的通用消息 DTO。

而不是一个为了兼容框架才临时出现的壳。

为什么 Msg 不是 String,这回应该真清楚了

如果要把这篇文章压成一句话,我觉得可以这么记。

Msg 不是把文本外面套一层对象,而是把 Agent 运行真正需要的身份、内容结构和运行语义收进同一个消息单元。

你也可以用这个表做最后的对照。

对比项

String

Msg

表达纯文本

可以

可以

表达消息身份

不行

role + name

表达多模态内容

不行

ContentBlock

表达工具调用

不稳定

ToolUseBlock

表达工具结果

不稳定

ToolResultBlock

表达结构化输出 / usage / generate reason

很别扭

metadata

适合进入 ReAct Runtime

不够

就是为这个设计的

所以从第 3 篇开始,你对 AgentScope Java 的理解应该再往前推一格。

前两篇我们拆的是。

Agent 不等于一次模型调用。

ReActAgent.call() 也不是普通方法调用。

这一篇再往下一层,就是。

Agent 运行里的最小沟通单位,也不是字符串。

它必须是结构化消息。

因为只有这样,后面的 Formatter、Tool 调用、Memory、Hook、结构化输出、Session 持久化,才能全都站在同一块地基上。

下一篇我们就接着往下拆。

为什么换模型的时候,Agent 代码可以尽量不动。

继续阅读

基于全文检索与主题相似度