第 2 章 读懂一次对话:Msg / Block / Event
本章代码
code/ch02/main.py· 读完约 15 分钟
2.0 上一章的 print 省略了太多信息
上一章那个循环里,每轮也只打了一行:
print(reply.get_text_content())把一个完整对象压缩成一个字符串,只为读取一句话。
但 reply 承载的信息远不止一句话:这一轮里模型推理了什么、调用了哪个工具、工具返回了什么、消耗了多少 token。这些内容都被 get_text_content() 一次性丢弃了。
为什么本章不给你 REPL。 接下来两章都用固定问题跑,不是退步:要观察同一条事件流的顺序、对比同一段工具轨迹,输入必须固定,否则每次跑出来的东西都不一样,没法对照。想看交互式的,自己套上一章那个
while循环就行——真正的交互闭环在第 13 章,那时输入框会出现在浏览器里。
本章要做两件事:看清一条消息的结构,看懂一次回复的过程。
前者决定数据如何存储,后者决定界面如何编写。
2.1 Msg:一条消息的结构
Msg 是 AgentScope 中最基础的数据结构。一次用户输入是一个 Msg,一次完整回复也是一个 Msg。
它有 11 个字段,但真正需要关注的只有 4 个:
| 字段 | 类型 | 作用 | 是否需要手动处理 |
|---|---|---|---|
name | str | 发送方名称 | 是 |
role | "user" | "assistant" | "system" | 发送方身份 | 是 |
content | list[ContentBlock] | 消息内容(重点) | 是 |
id | str | 唯一标识 | 自动生成 |
metadata | dict | 自定义键值对 | 偶尔 |
created_at / finished_at | str | 创建与完成时间 | 自动填充 |
usage | Usage | token 用量(仅 assistant 消息) | 第 14 章使用 |
finished_reason | ReplyFinishedReason | None | 本轮的结束原因 | 第 7 章使用 |
structured_output | dict | None | 结构化结果 | 第 10 章使用 |
error | ErrorInfo | None | 错误信息 | 第 13 章使用 |
后半部分标注了"第 N 章使用"的字段,现在可以先忽略。但它们的存在本身说明一件事:框架把"一次回复"视为一个完整事件——有起止时间、可能失败、可能携带结构化结果,而不是一个字符串。
2.2 content 为什么是列表
这是初学者最不习惯的一点:
reply.content # 不是 "你好",而是 [TextBlock(...), ToolCallBlock(...), ...]为什么不直接存字符串?
因为一次回复中可能发生多件事,这些事需要按顺序保留:

考虑这个场景:
你说:"看看 config.yaml 里数据库地址是多少"
它内部依次发生:
- 内部推理:需要先读文件 → 产生一个思考块
- 决定调用
Read→ 产生一个工具调用块 - 工具返回内容 → 产生一个工具结果块
- 基于结果作答 → 产生一个文本块
如果把这些内容压缩成一个字符串,第 3 步的结果就会丢失。下一轮模型会处于"记得自己读过文件,却不记得读到了什么"的状态,只能靠推测补全内容。
所以 content 是列表,其中包含六种块:
| 块类型 | 承载内容 | 常见来源 |
|---|---|---|
TextBlock | 纯文本 | 模型回答、用户输入 |
ThinkingBlock | 模型的推理过程 | 开启思考模式的模型 |
ToolCallBlock | 工具名、参数、执行状态 | 模型决定调用工具时 |
ToolResultBlock | 工具执行产出 | 工具执行完成后 |
DataBlock | 图片、音频、视频(base64 或 URL) | 多模态输入输出 |
HintBlock | 框架注入的提示(时间、任务数) | 自动注入,第 8 章会看到 |
一个可以立刻验证的问题
get_text_content()只拼接TextBlock中的文本。如果一次回复只有工具调用而没有文本(这种情况很常见),
2.3 role 的约束
role 不能随意填写,框架在构造消息时就会强制检查:
| role | 允许包含的块 |
|---|---|
user | 只能 TextBlock + DataBlock |
system | 只能 TextBlock |
assistant | 全部类型 |
约束的原因很直接:下游模型 API 就是这样规定的。不能给 user 角色放入工具调用块,因为用户本身无法发起工具调用。
这条约束在第 5 章会帮你一次:当你需要构造一条 assistant 消息来引导模型时,如果块类型放错,构造阶段就会失败,不必等到调用 API 才收到一个难以理解的 400 错误。
三个工厂函数可以省去手写 role:
from agentscope.message import UserMsg, AssistantMsg, SystemMsg
UserMsg(name="user", content="你好") # role 自动是 user
AssistantMsg(name="Buddy", content="你好呀") # role 自动是 assistant
SystemMsg(name="system", content="你是个助手") # role 自动是 systemcontent 传入字符串时会自动包装成 TextBlock。传入多模态内容时,改传块列表。
2.4 一条必须记住的规律
一次
reply对应前端的一个消息气泡。
这条规律现在看起来平淡无奇,但它决定了第 12 章前端的整个数据结构:数据库中不需要存"一条消息 = 一段文字",而要存"一条消息 = 一串块"。
而一次 reply 内部可能转了五轮、调用了八个工具——这些全部包含在同一个气泡里。
因此界面上正确的呈现方式是这样的:
┌─ 气泡 ────────────────────────┐
│ [思考] 得先看看目录里有什么… │ ← 折叠
│ [工具] Glob docs/**/*.md ✓ │
│ [工具] Read docs/01.md ✓ │
│ 14 个文件,索引已生成。 │ ← 真正的回答
└───────────────────────────────┘而不是每调用一个工具就生成一个新气泡。后者会让界面变得杂乱,历史记录也会被切碎。
2.5 事件:观察过程
reply() 返回的是结果。要观察过程,使用 reply_stream()。
# 消费掉中间过程,只取结果
reply = await agent.reply(msg)
# 逐个产出事件
async for event in agent.reply_stream(msg):
print(event.type)两者运行的是同一个循环,区别只在交付方式。reply() 内部实际上就是把 reply_stream() 产出的事件全部消费掉,再组装成一条消息返回。
reply_stream() 会产出哪些事件?共 28 种,按用途可分为七组:
| 组 | 事件 | 出现时机 |
|---|---|---|
| 一轮的开始与结束 | REPLY_START / REPLY_END | 包住整轮回复 |
| 模型调用 | MODEL_CALL_START / MODEL_CALL_END | 每次请求模型(一轮中可能出现多次) |
| 内容块生命周期 | TEXT_BLOCK_START / _DELTA / _END | 文本流式输出 |
THINKING_BLOCK_START / _DELTA / _END | 思考过程流式输出 | |
DATA_BLOCK_START / _DELTA / _END | 图片、音频等二进制数据 | |
HINT_BLOCK | 框架注入的提示 | |
| 工具 | TOOL_CALL_START / _DELTA / _END | 模型调用工具,参数流式拼接 |
TOOL_RESULT_START / TOOL_RESULT_TEXT_DELTA / TOOL_RESULT_DATA_DELTA / _END | 工具执行,结果流式回传 | |
| 人机交互 | REQUIRE_USER_CONFIRM / USER_CONFIRM_RESULT | 等待用户确认(第 7 章) |
REQUIRE_EXTERNAL_EXECUTION / EXTERNAL_EXECUTION_RESULT | 等待外部系统执行(第 13 章) | |
USER_INTERRUPT | 用户中断(第 13 章) | |
| 异常 | EXCEED_MAX_ITERS | 迭代次数超限,被强制终止 |
| 扩展 | CUSTOM | 自定义事件,框架预留 |
规律很明显:每种内容块都配有 START / DELTA / END 三个事件,因为流式输出的本质就是"开始、增量、结束"。
这个设计的价值在第 12 章才会完全体现:前端只需要一个 switch (event.type),就能同时实现打字机效果、工具卡片加载态和思考区折叠——因为它们在协议层面本来就是同一套机制。
2.6 把过程打印出来

完整代码约四十行:
def describe(event) -> str:
"""从事件中取出最值得关注的一个字段。"""
for field in ("delta", "tool_call_name", "state", "finished_reason"):
value = getattr(event, field, None)
if value is not None:
return f"{field}={value!r}"
return ""
async def main() -> None:
agent = Agent(name="Buddy", system_prompt="你叫 Buddy,回答简短。", model=build_model())
i = 0
async for event in agent.reply_stream(
UserMsg(name="user", content="用一句话说明什么是异步编程"),
):
kind = event.type.value
print(f"[{i:02d}] {kind:<22} {describe(event)}")
i += 1describe() 做的事很简单:在不清楚每种事件载荷的情况下,把可能存在值的字段逐个取出。这是了解陌生事件流最快的方法——先看全貌,再针对性处理。
运行后,你会看到类似下面的输出:
[00] REPLY_START
[01] MODEL_CALL_START
[02] THINKING_BLOCK_START
[03] THINKING_BLOCK_DELTA delta='异步'
[04] THINKING_BLOCK_DELTA delta='编程'
...
[31] TEXT_BLOCK_START
[32] TEXT_BLOCK_DELTA delta='异步编程'
[33] TEXT_BLOCK_DELTA delta='就是'
...
[58] TEXT_BLOCK_END
[59] MODEL_CALL_END
[60] REPLY_END finished_reason='completed'这段输出不是固定的。 使用不同模型、是否开启思考、是否装配工具,事件序列都会变化:
- 未开启思考的模型(例如普通 chat 模型)不会出现
THINKING_*系列事件- 一旦发生工具调用,中间会插入完整的
TOOL_CALL_*序列,MODEL_CALL_START也会出现第二次、第三次REPLY_END的finished_reason可能是completed、interrupted、exceed_max_iters或error不要记忆事件序列,而要理解这些事件产生的原因。
2.7 reply_id 与 block_id 的作用
每个事件上都带着这两个 id。现在看起来像冗余字段,到第 12 章会变成必需项。
原因:流式输出时,数据碎片可能交错到达。
假设模型同时要读三个文件,三个 Read 会并发执行,结果可能交错返回。前端会收到大量 TOOL_RESULT_TEXT_DELTA;如果无法判断某条增量属于哪一次调用,就会把文件 A 的内容写入文件 B 的卡片。
因此:
| id | 作用 |
|---|---|
reply_id | 整轮回复的标识。前端用它判断"这是当前这轮,还是上一轮迟到的数据" |
block_id | 每个内容块的标识。前端用它把碎片归位到对应块 |
tool_call_id | 一次工具调用的标识。调用与结果依靠它配对 |
一条必须遵守的规则:前端始终按 id 归位,不按到达顺序归位。
2.8 小结
Msg有 11 个字段,实际只需要处理name/role/contentcontent是块的列表,因为一次回复中可能包含多件事role有明确约束:user 与 system 不能随意放入块类型- 一次
reply对应一个气泡,这决定了存储结构与界面结构 reply()返回结果,reply_stream()产出过程;过程由 28 种事件组成,按"块的生命周期"组织- 数据碎片依靠
reply_id/block_id/tool_call_id归位,不依赖顺序
下一章要解决的问题:它目前只能对话。我们要让它能读你的文件、执行你的命令,那就必须给它工具。而一旦有了工具,它就会开始向框架提出请求:"我要调用 Read,参数是 path='a.txt'"。
