Skip to main content

流式输出为什么不能只推文本

一条回复里混着好几种东西​

上一篇结尾留了一个问题:工具执行时,页面要显示正在查询、执行成功或失败,需要用户确认的操作还会出现确认卡。用户这时刷新页面,这些状态和确认卡还能恢复吗?

回答之前,先看一轮真实对话里页面上到底有些什么。用户在比特严选的APP里发来一句:

我买的那块Apple Watch还没发货的话,帮我取消吧,不想要了。

这是T10订单变更。同样一句不想要了,订单是待付款、待发货、已发货还是已签收,走的路完全不同,Agent得先查清楚订单在哪一步再动手。下面这一轮是在本机服务上真跑出来的,用初始化账号登录,装的是比特严选数据集,从发出到停下约3.7秒:

  1. Agent先想了一小段,回复“我先看下取消订单的具体办理流程。”,然后调load_skill加载订单变更这份手册。技能是什么、手册怎么加载,后续章节会讲,这里只要知道它是一次工具调用。
  2. 调query_order查最近10笔订单,找到订单88235:一块Apple Watch Series 12(深空灰色、42毫米、铝金属、GPS)配一条回环式运动表带,实付3378.00元,状态是已支付待发货,可以直接取消。
  3. 回复“找到你的Apple Watch订单了……我这就提交取消。”,接着调cancel_order。取消订单是写操作,页面弹出一张确认卡,写着要取消的订单号88235,下面是确认和取消两个按钮。这一轮就停在这里,等用户点头。

这一轮前端收到的内容,落库以后是9块:思考、回答、工具(加载技能手册,完成)、思考、工具(订单查询,完成)、思考、回答、工具(取消订单,待确认)、确认卡。思考和回答被工具调用切成了好几段,交替着出现。

这里说的块,是一条助手消息里按出现顺序排下来的一段内容,每块有自己的类型和状态,随消息一起存进数据库。块里的文字是模型边生成边推过来的,一次推一小段,这一小段叫增量。

现在用户刷新页面。理想情况是看到和刷新前一模一样的9块,确认卡还在,按钮还能点。如果这条流像RAG问答那样只推一路文本、只存一段正文,刷新前页面就分不清哪段是思考、哪段是工具返回;刷新后确认卡直接没了。可这一轮在库里的状态还是等确认,同一个会话里再发一句话,后端会拒绝,返回:

上一步操作还在等你确认,请先确认或取消

这句是对着一个挂起的会话真发了一句话拿回来的原文。用户看不见卡,也发不了新问题,这个会话就卡住了。

所以这篇要回答的是:Agent这条流里要推哪几种东西,每种东西怎么开始、怎么结束,又怎么保证刷新前后页面上是同一条记录。

1. RAG那条流为什么够用​

Ragent的RAG问答也是流式输出。它的事件枚举SSEEventType有六个值:META、MESSAGE、FINISH、DONE、CANCEL、REJECT。推内容的只有MESSAGE,增量类型只有think和response两路,一路是思考,一路是回答。落库时调用ChatMessage.assistant(),传进去一段回答、一段思考和思考耗时,一条消息就是这两段正文。

这套结构对RAG是够用的。RAG一轮里可能调好几次模型,改写问题、识别意图都要调,但推给用户的只有最后生成答案那一次。那一次调用里模型先想后答,思考一整段、回答一整段,两段之间不会穿插别的东西。两个字段装得下,刷新后照着两个字段画出来,和刷新前一样。

2. 同一轮推成纯文本,页面缺了什么​

把开篇那一轮也按RAG的办法推:思考增量拼成一路,回答增量拼成一路,工具的返回内容当文字塞进回答里。页面拿到的是两长串文字,和那9块对照一下就能看出差别。

缺的东西页面上会怎样
这一段是什么思考、工具返回、回答混在一起
一段从哪到哪每段文字的起止时刻不知道,旁边那个生成耗时算不出来
会变的状态取消订单那一行要从待执行变成待确认,文字推出去就改不了,只能往后追加一行
按钮确认卡是一个能点的控件,不是一段文字

工具状态要更新,确认卡要能点,刷新后还要恢复这些内容,只存一段思考和一段正文就不够了。

Agent每轮的块数和顺序也不固定,取决于模型怎么回答、怎么调用工具。所以要按块组织内容,记录各块的类型、状态和起止时间,前端按顺序展示,落库时也存成同样的列表,刷新后再按列表还原。

八种事件、五种块​

Agent这条流没有复用WorkFlow的协议。事件枚举AgentSSEEventType的类注释写的是“与workflow协议两套分立”,workflow是项目对RAG那条编排管线的叫法,RAG那六个事件一个没动。

1. 事件按职责分成四类​

下面八种事件是Ragent当前的协议设计,并不是Agent流式输出必须固定使用的八种事件。按职责可以归成开场、增量、结构与提示、收场四类。下表最后一列沿用开篇那一轮的记录,按当前协议归类:原来的5次文本时间更新和7次工具更新,统一计为12次BLOCK。线上发的事件名都是小写,meta、message这样,表里按枚举名写:

类别事件带什么开篇那一轮按当前协议归类的事件数
开场METAconversationId、taskId1
增量MESSAGE内容类型answer、reasoning或error,加一小段文字233
结构与提示BLOCK按kind更新对应块:补充文本时间,或更新工具状态、结果和执行时间12
结构与提示HINT运行过程中的临时提示0
收场FINISH、CONFIRM、CANCEL三选一,后面总跟一个DONEmessageId、会话标题、本轮耗时;FINISH和CANCEL还带消息状态,CONFIRM还带待确认的调用CONFIRM 1、DONE 1

META在任何内容之前到,带会话id和这一轮的taskId。新会话的id是后端收到请求时才生成的,前端靠它把这一轮挂到侧栏对应的会话下;用户点停止时,前端要拿taskId找到正在跑的这一轮。这两样都得在第一段文字到达之前就位,所以META排第一个。

BLOCK是Ragent自己定义的“块更新”事件,职责是通知前端更新某个内容块的信息。MESSAGE负责逐段追加文字;BLOCK则补充文本块的时间,或创建、更新工具块。前端收到BLOCK后,通过kind判断要处理哪种块。

放到开篇取消订单的例子里看:

文本更新:kind为answer、reasoning或error。“我先看下取消订单的具体办理流程。”这句话已经通过MESSAGE逐段推完。开始加载手册时,前后端都会按分块规则结束这段回答;随后,后端发送BLOCK,把这段回答的起止时间和生成耗时134毫秒交给前端。前端据此更新这块的时间信息,不需要重新接收正文,也不靠BLOCK决定在哪里分块。这一轮有3段思考、2段回答,所以有5次文本类型的BLOCK。

工具更新:kind为tool。前端首次收到某次调用的更新时,创建工具块;后续收到同一次调用的更新,就修改这个块的状态、结果和执行时间。例如,加载手册只执行一次,后端会分三次告诉前端它进行到了哪一步:

什么时候后端发来的BLOCK(kind: tool)页面上的同一行更新为
模型发起load_skill调用status: pending加载技能手册 · 待执行
工具开始执行status: running加载技能手册 · 运行中
手册加载完成status: done,带回手册正文加载技能手册 · 完成

三条事件带着同一个toolCallId:第一条创建“加载技能手册”这一行,后两条更新这一行,不会画出三行,落库时也只有一个工具块。

查询订单同样经历这三个状态,完成那条事件带回订单列表和8毫秒的耗时。取消订单则只发出一条pending事件,还没执行就停下来等用户确认了;随后收到CONFIRM,前端把它改成待确认,并显示确认卡。因此表里的7次工具更新 = 加载手册的3条 + 查询订单的3条 + 取消订单的1条,对应的是3次工具调用,其中取消订单尚未执行。

为什么叫BLOCK,而不叫TOOL? 因为它既更新工具块,也更新回答、思考等文本块。名字表示“更新块”,kind表示“更新哪种块”,职责就对得上了。它不负责所有通知:文字增量由MESSAGE发送,确认卡由CONFIRM处理,本轮结束由FINISH等收尾事件通知。

HINT:显示临时运行提示。这一轮没有触发它。比如达到最大迭代次数时,系统会提示“已达到最大迭代次数,正在生成当前执行结果的总结”,让用户知道接下来在做什么。它只在当前页面显示,不落库,刷新后不会恢复;确认卡则由CONFIRM事件处理。

协议里没有单独的错误事件。出错时发一条error类型的MESSAGE,再发FINISH。什么情况下会出错、出错后怎么收场,后面讲出错处理时再说。

收场的三个事件每轮只发一个,发完总跟一个DONE,负责发它们的sendTerminal()注释写的是“终态事件与done成对发出”。前端见到DONE就知道这条流不会再有东西来了。

2. 五种块​

前面说的事件,是后端陆续发给前端的通知;这里说的块,是一条助手消息里实际展示的内容。一段思考、一段回答、一次工具调用、一张确认卡或一条中断提示,各自占一块。代码用AgentBlock保存每一块的信息,再把这些块按出现顺序存进消息里,刷新页面时就能按原来的顺序还原。

2.1一段文字怎么组成一个文本块​

模型回复“我先查一下订单。”时,这句话通常不会一次到齐,而是分几次推过来。假设中间没有工具调用,也没有切换成思考,前端会这样接收和显示:

收到第几次这次新来的文字页面上这段回答已经显示的文字
第一次我先我先
第二次查一下我先查一下
第三次订单。我先查一下订单。

每次新来的那几个字叫增量,三次文字接在一起,组成一个回答块。所以收到三次增量,页面上仍然只有一块回答。

除了回答,思考和异常中断提示也用文本块保存。三种文本块的区别在于文字的用途:

  • answer:模型说给用户听的话,比如“我先查一下订单。”。
  • reasoning:模型的思考内容,比如先判断应该查询哪笔订单。
  • error:系统在本轮异常中断后补上的提示,比如提醒用户核对提交操作是否已经生效。

每条文字增量都会带上这三种类型之一,前端据此知道该把文字放进哪种块。上面三次增量的type都是answer,最后组成的块,其kind也是answer。思考和中断提示同理,传输时和保存时使用相同的类型名称。

后端的事件桥AgentStreamEventBridge一边把增量发给前端,一边也按同样的规则积累文本块,最后随消息一起落库。下文会继续讲,一段文字在什么时候结束、下一块又在什么时候开始。

解锁付费内容,👉戳​