




一句"现在几点",换来 44 条事件
第一课跑完,我把面板上那句"现在几点"引出的 44 条事件从头数了一遍:思考增量 15 条,正文增量 17 条,中间夹一次工具调用、一次工具结果,再算上各家族的首尾事件、两头 RUN 和两条账单,一共 44 条。一个问时间的动作,动静比我想的大。本以为读懂 agent 的事件流得先把几十个事件名背下来,实际只差一句话:它会进历史吗?------进,是消息;只流不留,是事件。
先把证物钉在桌上:后面所有结论都从这份序列里读出来。这是我试验台(Spring Boot,JDK 21,模型走 DeepSeek 真流式)上真采的序列,零碎的增量碎片(下文按协议叫它 delta)折叠之后剩 6 组,摊开正好 44 条(对账明细在第 4 节过堂时给):
text
RUN_STARTED
REASONING ×15 ← 第一轮:模型先想,思考是一条条 delta 淌出来的
TOOL_CALL ← 模型吐出的意图:调 get_current_time
RESULT ← 工具结果回流
TEXT ×17 ← 第二轮:模型直接作答,17 条正文 delta
RUN_FINISHED
# 另有两条 CUSTOM(token_usage),对应两次模型调用,各结一次账
这份序列里大半是过程:delta 淌完即逝,真正留在历史里的只有几块。"进历史"三个字怎么数出来的?服务端会话存储和事件面板各有一份记录,对着看就行:存储里留下来的,是消息;面板上闪过、存储里翻不到的,是事件。44 条里最典型的一条是工具结果------作为事件流过一次,转头又作为块在历史里存了一份,一个身份两种命。
这篇讲的就是这个分法。
消息世界:Msg 是信封,内容是类型化信纸
比事件更底下还有一层:一场对话在框架里到底是什么数据结构。我对着 agentscope-core 2.0.3 的字节码清点过(javap io.agentscope.core.message):对话的每一步是一个 Msg,官方原话 "Msg (io.agentscope.core.message) represents one turn of conversation";Msg 里装的不是字符串,是块列表 List<ContentBlock>,官方同样一句话说破:"Message content is composed of typed blocks, each representing one type of information."
拿类比说话:Msg 是信封,四种角色各一款------信封上那行角色就是 role 字段;对话历史也没有玄乎的实体,就是这摞信封按顺序码好。信纸是块,九种,一个 List<ContentBlock> 就是一叠信纸,一封信可以装多张。说"不是字符串"是有对照的:裸协议里思考、正文、工具调用糊成一坨字符串,想分开处理连下刀的地方都没有;块把刀口替你开好了。常用四张先记脸:正文块 TextBlock、思考块 ThinkingBlock、工具意图块 ToolUseBlock、工具结果块 ToolResultBlock------开场 44 条里真正进历史的,就是这几位的拼装成品。剩下五种本篇用不上,全家福见图。

有个新规矩得单独说:角色约束在构造期就生效,官方原话 "Role constraints are enforced at construction"------USER 信封只装文本和媒体块,SYSTEM 信封只装 TextBlock,装错了在 new 那一下就抛,不用等寄出去才炸。写 Java 的会眼熟:这是把校验左移到构造器,跟 Spring 里构造参数断言一个脾气。
工具调用链也在这说清,它有三个时刻。时刻一,注册:@Tool 方法配 registerTool,启动时装一次箱。时刻二,每轮宣告:模型无状态,跟 HTTP 服务器一个记性,不记你上一个请求,框架每一轮都把工具清单塞进 stream() 的第二参数重新说一遍,指望模型"记得上一轮说过"是要吃亏的。时刻三,模型只出意图:吐一个 ToolUseBlock 就完事,真正执行工具的是 agent 循环------模型只管点菜,做菜上菜都是循环的事。意图和执行中间这道缝,就是往后权限审批的挂载点。往缝里塞一个"执行前先问人",审批就有了,那是后话。
我自己第一遍就卡在这儿:"那我之前不应该告诉模型,有这个工具吗?"------时刻二就是"告诉",但模型记不住,所以每轮都得重新说;"告诉"发生在每轮请求里,不在模型脑子里。
类型化是全文的根。块有类型、有边界,框架才有缝隙往里做东西:权限审批卡在意图和执行之间,超大工具结果能在进历史前压缩,思考和正文可以分开渲染。裸字符串协议没有块边界,这三件事一样都做不了------chat-completions 腿丢信息、AG-UI 腿不丢(AG-UI 这条腿是什么,下一节细说),总根在这。落到使用上是:同一份对话,走 AG-UI 腿能在前端把思考折叠起来单独渲染,走 chat-completions 腿拿到的只是一坨拼接好的文本。
信封信纸这套类比到这收住:它帮理解,不是机制等价,读代码时认 Msg 和 ContentBlock 就行。还有个预告:同一份内容在两侧各有一套名字------块的世界 9 种,事件的世界 31 类------名字对不上不要紧,下一节的表格负责牵线。
事件世界:三件套,而且事件比块多
过程那一侧是另一个包:io.agentscope.core.event。我拿 javap 全量清点过:31 个具体事件类(官方文档另有一份归并方式不同的清单,我不拿它来对数),大半按块家族配成 Start→Delta→End 三件套------思考一套,正文一套,工具调用也一套。官方文档的说法是 "Events follow a start → delta → end pattern";事件驮的是增量:"Events carry incremental progress updates (text tokens, tool-call fragments, permission requests, ...)";同一次 reply 里的全部事件共享同一个 replyId。拿思考族把三件套走一遍:START 声明"要来一块思考了",DELTA 一条条驮片段,END 是收工的吆喝------真正被留下来的,是吆喝声里拼好的那块信纸。
块和事件谁认识谁?我自己就带着这个问题:"这就是一个msg块吗?"(第一次对着面板,我指着一条事件 JSON 问出的就是这句。)不是。它们是单向配对:delta 事件装着块的片段,拼装完成才成块、才进历史;反过来,块对事件一无所知,它不知道也不需要知道自己被哪串 delta 拼过。顺着这个方向,另一种猜法------每个 msg 块里挂着一个 event.getType()------也是反的:没有谁挂在谁里面,事件装片段,块是成品。还有一句可以一起记:事件可以没有块,块不会没有来路。这个单向性还有个实际用处:面板上看到半截思考别慌,等 END,成品要拼完才进历史,拼装没完成前,面板和存储本来就不该对得上。
31 比 9 大得多,多出来的那批没有对应块:生命周期(RUN_STARTED、RUN_FINISHED)、可观测(token_usage 搭的 CUSTOM)、还有 HITL(human-in-the-loop,把人拉进决策环的那类)。过程信息比状态信息多:事件流里永远有一批不进历史。这个不对称是设计使然:状态要省着用,每一轮都要重发、都算钱;过程尽管放量,看完就丢,不占历史一分钱。一边是存档,一边是直播。
顺手排两颗雷,各一句:EventType 枚举只有 6 个常量,管过滤用,不是事件类------我最初整理事件清单时把枚举也混了进去,拿 javap 数出 31 个才发现对不上账;hook 包里的 *Event 不走主事件流,别在面板上找它们。
还有一句官方原话让我心里踏实:"the full message state can be reconstructed from the event stream alone"------事件流是消息世界的投影,而且完整到能拿它重建整个消息状态。(前面说"块对事件一无所知"跟这句不冲突:一无所知,说的是运行时里块身上没有指回事件的引用;可重建,说的是事件流带的信息够全,片段齐了就能把块拼回来。方向本来就是单向的。)AG-UI(Agent-User Interaction,专门定义 agent 怎么向前端推事件的开放协议,走 SSE,服务端单向推送)敢让前端只靠事件流渲染,底气就是这句。内核事件类到面板词汇隔着一层翻译:你在面板上看到的 REASONING_MESSAGE_*,不是内核类名,是翻译层给的展示名。常用的对照抄在这:

表读下来重点在最后一列:进历史的从来不是事件,是事件拼出来的块;没块可拼的那两行,永远只流不留。我一开始拿 CUSTOM 当模型调用专用------不是,它是 AG-UI 的通用容器,token_usage 只是搭了这趟车。
把 44 条逐段过堂
现在把开场那 6 组拆开逐段读,读法只有一问:它会进历史吗?每段判完再往下走。先把 44 的账对上:思考族 17 条(START + 15 条增量 + END),工具调用族 3 条(START + 参数 + END------DeepSeek 真吐了参数增量),工具结果 1 条,正文族 19 条(START + 17 条增量 + END),RUN 两端 2 条,token_usage 账单 2 条。17+3+1+19+2+2,正好 44。口径先交底:JDK 21,DeepSeek 真流式,一个 token 一条 delta。agent 本体朴素得很,凭当时的笔记复述个骨架(参数细节下篇拆):
java
ReActAgent.builder()
.sysPrompt("回答前先想清楚;需要当前时间就调用工具。")
.model(deepseekModel)
.registerTool(new TimeTools()) // @Tool 方法装箱,启动只做一次
.build();
一句 sysPrompt 换来 15 条思考 delta------模型真的在逐 token 想,一条接一条往下淌。思考流里有一句原话:"I should call get_current_time.",模型把"该调工具"这件事想成了一段独白。判:独白拼成思考块,进历史。顺带对一下身份:这段独白也是一条条 delta 淌出来的,思考照样走流式,不是想完了才整段冒出来。
TOOL_CALL 那一族:工具名 get_current_time,参数 "{}"------这个工具不需要入参,模型还是按协议把参数对象吐全了。toolCallId 是一长串 call_00_M2e1tkjpTiNHQrjqWMUS9522。判:拼成工具意图块,进历史。紧跟着 RESULT,工具把时间查出来回流。工具结果块,进历史------第 3 节表格里"双重身份"说的就是它。
路上有个当时让我愣住的小坑:RUN_STARTED 里 input.tools=[]。事件带给前端的是张空单子------工具清单根本不跟事件走,它一直在服务端的 Toolkit(框架的工具箱)里,每轮由框架塞给模型,就是第 2 节讲的时刻二。
为什么有第二轮?第一轮里模型想完没作答,选择调工具------循环执行完把结果塞回历史,再把变长的历史重新发给模型,这就转出了第二轮。两轮之间没有魔法,就是历史长了一截、重发了一遍。第二轮是 TEXT ×17,逐 token 作答。这一轮没有 REASONING------模型这次没出思考块,没块就没事件族,一族都不给,序列形状忠实于过程。判:正文块,进历史。
最后看账本,这段我盯着看了很久。账本三处,各对一问:
text
inputTokens: 304 → 370
cachedTokens: 128 → 128
cumulative: 674(304 + 370)
多出的 66 个 token 装的什么?正是上一轮进历史的三样:思考块、工具意图、工具结果。历史长一截,下一轮输入跟着长一截,+66 就是"消息向后累积"的定量证据。至于第一轮那个 304 的构成(系统提示、问话、工具清单各占多少),我没逐项核过;不影响归因,+66 只算增量,增量的三样能从历史里点名。
cumulative 674 对上两次调用的账:两条 CUSTOM token_usage 各自的 replyId 与对应消息的 messageId 对齐,两次模型调用的铁证。
cachedTokens 恒 128,留意一句:两轮的 128 一模一样,命中的正是开头那截没变的前缀:sysPrompt 待着没动。

图里两条泳道把两个世界画在同一张纸上:左边哗哗往下淌的是过程,右边一行行长上去的是状态。
换上假模型照镜子:变的与不变的
序列里哪些东西是 DeepSeek 带来的,哪些是框架自己干的?把模型换掉就照出来了。fake:fake 是我自己写的确定性假模型:回放一段写死的剧本,同输入永远同输出,不联网,不要 key。写它不为省钱,为做对照:控制变量,只换模型这一个变量,序列里的差异从哪来就照出来了。关键是框架的态度:它眼里假模型跟 DeepSeek 完全平等,模型抽象只要求你按约定吐 ChatResponse。我自己第一次换上它也犯嘀咕:"假模型?还会触发模型调用以及思考吗?"------会,该走的事件一族都没少;FakeChatModel 返回消息的代码是 content(List.of(new ToolUseBlock(...))),手工构造一张信纸而已。试验条件先摆平:换模型这趟服务重启过,历史清零,同一个问题从零问起。
动手前我先写了预测(这规矩哪来的,第 7 节交代):正文块应该像 DeepSeek 那样碎成一堆 delta。实测:每个块恰好 1 条。预测错了,错的留在这。错得有信息量:我把"流式"当成了块的属性,其实它是传输的属性。原理就是下一条。
镜子里照出四条差异,每条背后都是一个能搬走的机制。
一,delta 条数是流式粒度,不是块大小。DeepSeek 逐 token 吐,正文 17 条;fake 一次给整块,1 条。块还是那个块,变的是管道的脉冲频率。
二,usage 缺失,整个事件不发。我盯着 fake 的 ChatResponse 问过:"ChatResponse 没填 usage,这个是什么意思?"------我本来也等着对账 token,结果一条 token_usage 都没来。对照 DeepSeek 那边:真模型每次都带了 usage,两条 token_usage 准时报到(我这边是开了 emit-token-usage 开关才有账)。同一个开关,fake 这边开着也可能安静------框架对缺失数据的兜底是"缺位",不是"补零":不补 0,也不发带空字段的事件。账本宁缺毋假。
三,思考与正文共用 messageId 基座。fake 的 REASONING 和 TEXT 两条消息 id 前缀相同,它们本是同一个 ChatResponse 里的两个块。
四,ARGS delta 缺席。DeepSeek 会真吐参数增量(那段 "{}"),fake 一次没吐。我的推断:完整响应没有逐字可流的东西,框架也不替它合成 args 增量------这是推断,待 starter 源码确认,先跟实锤分开摆。这条对读序列的人最实用:看到没有 ARGS delta 别急着当 bug 报,先看看模型是真流式还是完整响应。
这面镜子最大的用处是把变量隔干净:四条差异都从"真流式 vs 完整响应"这一个对照里长出来------一、四归流式粒度,二、三归响应的构造方式;没变的部分------三件套结构、单向配对、进历史的判决------才是框架本身的形状。换个说法:看完这节,你该能分清哪些行为属于模型、哪些属于框架。排障的时候,这就是"该找谁"的问题。

另记一笔,假模型那轮的工具结果:时间 09:51:26,toolCallId 恒为 call-001。记住这个时间,下一节有用。
历史长一行,过程换形状
不重启服务,同一个 threadId(273f021e...,thread 是会话)再发一遍"现在几点"。新 runId 060c62fb(run 是一轮执行),序列变成:RUN_STARTED → REASONING ×1 → TEXT ×1 → RUN_FINISHED。思考 1 条 delta,正文也 1 条------假模型一开口就是整块,上一节讲过它的流式粒度。零工具调用,零 token 账单,上来就是思考加回答。把前后两遍摆在一起看:同一个问题,序列形状完全两样,变量只有历史那一行。
根因不在事件层,在消息层:上一轮的工具结果还挂在历史里。假模型开场先扫一遍重发过来的历史,toolResultSeen(剧本里管"见没见过工具结果"的一个布尔标志)第一遍扫描就命中,直接跳进第二段作答------它每轮看到的"世界",就是那摞重新寄过来的信封。历史只长了一行,过程整个换了个形状。

这是我这课最想带走的手艺:agent 突然不调工具了,先查消息层。这个症状将来大概率撞见:提示词没动、模型没换,agent 就是突然"学会"不调工具了;先别急着改提示词,把历史打出来看一眼,看看里面是不是已经躺着答案。
彩蛋在对账的时候。假模型撒了个谎:它回答的时间是 09:57:36,历史里工具结果明明是 09:51:26,差 6 分多钟。翻剧本才看到,第二段构造响应时现算了个 LocalDateTime.now()------它是剧本,不是有状态智能体,时间只是碰巧每次都新鲜。真实模型同款毛病有名字,叫 confabulation:一本正经把编的当真的说。fake 这是我故意留的教具,它妙在给了我一个本地可复现的 confabulation 标本:对账方法简单,拿回答里的时间对历史里的工具结果,一对一个准。真实模型犯起来,可没这么理直气壮。
你也能跑一遍:一条免 key 的命令
现在轮到你。先交个底:我的沙盘(一个自用学习项目)没有开源,下面这条命令你多半没法直接跑。两条路。已经有接了 AgentScope 的项目:自己照模型抽象写个假模型(吐固定剧本就行),换上去,后面每一步都跟你有关,指纹换成你自己的。还没有:先跑官方 Quickstart 起一个最小 agent,跑起来的过程同样往外淌事件,照样可以"先预测再对照",只是没有我的面板和指纹。
持有同款沙盘的(或者想看我这边怎么跑的),一条命令,不要任何 API key:
bash
./mvnw spring-boot:run -Dspring-boot.run.arguments=--workbench.agent.model=fake:fake
这行命令两层套娃:外层 ./mvnw spring-boot:run 把 Spring Boot 应用跑起来;内层 -Dspring-boot.run.arguments= 把 --workbench.agent.model=fake:fake 当应用参数塞进去,agent 的模型就从 DeepSeek 换成 fake 了。我的环境:JDK 21 加 Maven wrapper,命令在 Git Bash 里跑;Windows 下 PowerShell 的参数写法不同,别硬套。
应用起来后打开页面(默认 http://127.0.0.1:8080),在输入框敲"现在几点"。敲之前停一下,先拿纸把预测写出来:几条事件、折成几组、工具调用站在哪个位置,顺便再押一道:这回会有 token_usage 事件吗?第 5 节刚讲的兜底逻辑,正好拿来当预测用。先拿纸写预测再对照面板,这个顺序别省。我第一遍就想直接开面板,摁了自己两次才服气:预测落了笔,看面板时才知道哪些是真懂的、哪些只是眼熟。给个反面教材:上午刚纠正过的 AG-UI 事件族名,下午落笔预测,我又写回了旧错名。看过不等于记得住。
发出去,对三个指纹:工具结果的 toolCallId 恒为 call-001;回答以"时间查询完成"开头;思考原文每次一字不差(拿"每次"对比时,重启服务或新开会话再问一遍,同起点比;同线程连问会走第 6 节那条第二段剧本的路,思考原文会变)。形状也核一下,11 条、零 token 账单,这是框架行为保证的,你跑出来也该是这个数。分账在这:思考族 3(START + 1 条增量 + END),工具调用族 2(START + END,无参数增量),工具结果 1,正文族 3,RUN 两端 2------3+2+1+3+2=11。我说"折叠 6 组",按的是面板上相邻的块面(RUN 开、工具、结果、思考、正文、RUN 收);照第 4 节的家族口径数是 5 族,因为零账单、RUN 两端合成了一族。两种数法都对,别在这上面怀疑自己。三个指纹是我这版剧本的特征,全对上说明你跑的就是我看到的那出戏;顺带说破其中一个的来历:思考原文一字不差,是剧本的副产物,写死的字符串当然次次一样。
跑的过程有任何对不上的地方,回第 5 节的镜子照一照------假模型的每一条行为,那节都给了解释。走 Quickstart 路的读者,对答案时盯形状就行:思考、正文走完整的三件套,注册了工具的话工具调用出现、结果回流,最后有收场事件。自写假模型的,你的剧本是什么样,指纹就是什么样。
带走三句,临走三问
回头看开场的 44 条:现在能一眼把它们分进两个世界------两条 RUN_* 记完开场收场就走,两条 CUSTOM 结完账就走,三十多条 delta 是拼装中的过程;真正进历史的,是拼出来的四样块:思考、意图、结果、正文。带走三句都是从输出里长出来的。机理前面讲完了,才敢把它们压成口诀:
拿到事件流里任何一条 JSON,只问一句:它会进历史吗?进,是消息;只流不留,是事件。
+66 不是模型变贵了,是历史变长了------304 到 370 的差,装着上一轮进历史的思考、意图和工具结果。
agent 突然不调工具了,先查消息层:历史里躺着答案,它就不动手了。
临走三问,场景都给了,答案自己跑一遍或者翻前文:
- 面板刷过一条 REASONING_END,你在服务端会话存储里找不到同名的它。它进历史吗?进的是它,还是别的什么?
- 同事盯着 304→370 的账本问:多出来的 66 个 token 装的是哪三样?按进历史的顺序报得出来吗?
- 把 fake 沙盘重启(历史清零),再问一遍"现在几点"------序列变回什么形状?没连问过第二遍的,先不重启再发一遍。跟那一遍比,哪些组回来了,哪些消失了?
三道都答对了,这一课才算真的带走。
这是 AgentScope Java v2 系列第 1 课。下一篇进 ReActAgent:builder 五个参数挨个拆,重点看 maxIters 顶到头会发生什么。agent 想一直调工具而不让它调的时候,事件流里会留下什么形状?第 1 课这套判别法,到那儿还要再用一次。
AgentScope Java v2 第1课,消息与事件模型
如果这篇文章对你有帮助,欢迎转发给需要的同事和朋友。