dodo-agent

分阶段流式输出

在之前的课程中,我们的各个 Agent(WebSearchReactAgent、FileReactAgent 等)一直使用 AgentResponse 来构建流式响应,输出类型只有 text、thinking、reference、r…

TL;DR

在之前的课程中,我们的各个 Agent(WebSearchReactAgent、FileReactAgent 等)一直使用 AgentResponse 来构建流式响应,输出类型只有 text、thinking、reference、r…

在之前的课程中,我们的各个 Agent(WebSearchReactAgent、FileReactAgent 等)一直使用 AgentResponse 来构建流式响应,输出类型只有 text、thinking、reference、recommend 等几种,功能相对单一。 但是,当我们构建 SkillsReactAgent 这个通用型智能体时,用户在界面上看到的不再只是"思考 → 回答"这么简单的过程。SkillsReactAgent 会调用各种工具(搜索、文件系统、Bash、Skills 等),用户需要实时看到: - 模型正在思考什么 - 正在调用哪个工具 - 工具执行是否成功 - 出错了是否在重试 - 最终的回答是什么 为了满足这些需求,我引入了 分阶段流式输出 的设计,通过 AgentStreamEvent 事件模型将 Agent 的执行过程拆分为多个阶段,逐阶段推送给前端。

为什么只改造 SkillsReactAgent?

可能有的同学会问:为什么不把之前的 Agent 也一起改造了? 主要原因是和之前的 Agent 做区分。之前的 WebSearchReactAgent、FileReactAgent 等是已经完成的功能,如果对它们侵入性太强地改造,会导致我们前面的课程出现不一致的情况。它们的输出逻辑已经稳定运行,没有必要为了统一格式而大规模重构。 而 SkillsReactAgent 是新构建的通用型智能体,从一开始就设计了更丰富的事件模型。这样做的好处是: - 不影响已有功能:老 Agent 的输出格式不变,前端也不需要改动对它们的处理逻辑 - 新旧并存:前端通过事件类型(type 字段)区分不同的 Agent 输出,两者互不干扰 - 按需演进:将来如果需要把老 Agent 也迁移到新的事件模型,可以逐个改造 | Agent | 响应机制 | 事件类型 | | --- | --- | --- | | WebSearchReactAgent | AgentResponse | text、thinking、reference、recommend | | FileReactAgent | AgentResponse | text、thinking | | PPTBuilderAgent | AgentResponse | text、thinking | | PlanExecuteAgent | AgentResponse | text、thinking | | SkillsReactAgent | AgentStreamEvent | text、thinking、tool_start、tool_end、error、complete |

AgentStreamEvent

AgentStreamEvent 是一个 sealed interface,使用 Java 的密封接口 + record 来定义事件类型:

public


}

每种事件类型都是一个 record,实现 toJSON() 方法输出统一格式的 JSON。

事件类型

Text — 正文 模型的正式回答内容,最终展示给用户看的内容:

record


}
{"type":"text","content":"我来帮你分析这个问题..."}

Thinking — 思考过程 模型的思考内容(从 MiniMax 的 标签中解析出来),展示为可折叠区域:

record


}
{"type":"thinking","content":"我需要先搜索一下相关信息..."}

ToolStart — 工具开始执行 当模型决定调用某个工具时,立即通知前端工具开始执行:

record


}
{

关键点:ToolStart 在工具实际执行之前就发送,这样前端可以立即展示"正在执行"的状态,而不是等工具执行完才显示。 ToolEnd — 工具执行完成 工具执行完成后(无论成功还是失败),发送结果:

record


}
{"type":"tool_end","toolName":"tavily_search","toolCallId":"call_abc123","result":"{\"results\":[...]}"}

工具执行失败时,result 中会包含错误信息:

{"type":"tool_end","toolName":"bash","toolCallId":"call_xyz","result":"{ \"error\": \"工具执行失败:command not found\" }"}

Error — 错误事件 用于 LLM 调用失败时的错误通知,配合重试机制使用:

record


}
{"type":"error","code":"LLM_CALL_FAILED","message":"LLM 调用失败,正在重试 (1/3)","detail":"Connection timeout"}

Error 事件有三个字段: - code:错误码,如 LLM_CALL_FAILED,前端可以据此做差异化处理 - message:面向用户的错误描述 - detail:技术细节,如异常信息 Complete — 执行完成 整个 Agent 执行结束时发送,标志所有工作已完成:

record


}
{"type":"complete"}

注意:之前的 Agent(使用 AgentResponse)在结束时只调用 sink.tryEmitComplete() 关闭流,不发送 Complete 事件。而 SkillsReactAgent 先发送 Complete 事件,再关闭流,前端据此做状态清理。

事件是如何产生的

了解了 6 种事件类型后,我们来看这些事件在 SkillsReactAgent 的 React 循环中是如何产生的。

事件流转全貌

一个完整的 SkillsReactAgent 执行流程中,事件的产生顺序如下:

用户发送问题
    ↓
Round 1 开始(scheduleRound)
    ↓ processChunk
    → Thinking 事件(模型正在思考)
    → Text 事件(如果模型直接回答)
    → 或进入 ToolCall 模式
    ↓ finishRound
    → 如果有 tool_call → executeToolCalls
        → ToolStart 事件(工具开始执行)
        → 工具执行...
        → ToolEnd 事件(工具执行完成)
    → 如果没有 tool_call → Complete 事件(执行完成)
    ↓
Round 2 开始(如果上一轮有工具调用)
    ↓ processChunk
    → Thinking 事件
    → Text 事件 或继续 ToolCall
    ↓
    ...
    ↓
最终 Round
    → Complete 事件

processChunk:Thinking 和 Text 事件的产生

在每轮的 processChunk 中,LLM 的流式输出被逐 chunk 处理。结合上节课讲过的 ThinkTagParser,思考内容和正式回答被拆分为不同的事件:

private


        state


        state


                sink


                sink
                state


}

这里有个关键逻辑:一旦发现 tool_call,就不再处理文本内容。因为模型在决定调用工具时,content 中的文本通常只是空字符串或者过渡性内容,不需要展示。

executeToolCalls:ToolStart 和 ToolEnd 事件的产生

当 finishRound 检测到本轮有 tool_call 时,调用 executeToolCalls 逐个执行工具:

for


        sink


            sink


            sink


            toolRecords


            sink


}

几个要点: - ToolStart 在工具执行前发送:前端可以立即显示"正在执行"的动画 - ToolEnd 无论成功失败都会发送:失败的 ToolEnd 的 result 中包含错误信息,但不会触发 Error 事件 - 工具执行是并发调度的:通过 Schedulers.boundedElastic().schedule() 调度,多个工具可能并行执行 - ToolRecord 记录执行历史:每次工具调用的名称、ID、参数、结果都记录在 toolRecords 中

finishRound:Complete 事件的产生

当一轮结束后,如果没有 tool_call(即模型给出了最终回答),发送 Complete 事件:

private


        sink
        sink
        hasSentFinalResult


                    conversationId


}

RoundState 中的 mode 字段是判断本轮是否有工具调用的关键。processChunk 中一旦发现 tool_call,就把 mode 切换为 RoundMode.TOOL_CALL。如果整轮结束时 mode 不是 TOOL_CALL,说明模型直接给出了最终回答。

重试机制与 Error 事件

SkillsReactAgent 有两个层面的错误处理:LLM 调用层面的重试 和 工具调用层面的自修复。

LLM 调用层面的重试

当 LLM 的流式调用本身出错(如网络超时、模型服务异常、Token 限制、APIKEY错误等)时,scheduleRound 中的 onErrorResume 会自动重试:

Disposable


                sink

                        err


                                conversationId
                        RETRY_INTERVAL_MS


                sink

                        err
                sink
                hasSentFinalResult
                sink

重试的关键参数: - maxRetries:最大重试次数,默认为 3,在 Builder 中设置 - RETRY_INTERVAL_MS:重试间隔,固定为 10000ms(10 秒) 重试流程: 1. LLM 流式调用出错 → 触发 onErrorResume 2. 如果未超过最大重试次数 → 发送 Error 事件通知前端,等待 10 秒后重新发起 scheduleRound 3. 如果超过最大重试次数 → 发送 Error 事件 + Complete 事件,结束整个流程 你可能注意到,重试时用的是 Schedulers.boundedElastic().schedule(runnable, RETRY_INTERVAL_MS, TimeUnit.MILLISECONDS) 而不是直接调用 scheduleRound。这里的关键原因是需要延迟:LLM 调用失败后立即重试很可能继续失败(比如服务端限流、网络抖动),等待 10 秒后再重试成功率会相对高一些。 前端收到 Error 事件后,会在时间线(Timeline)中展示错误信息,用户可以看到"正在重试 (1/3)"等提示。

工具调用层面的自修复

工具执行失败时,不会触发 Error 事件,也不会触发重试。失败信息作为 ToolEnd 的 result 返回给模型:

// 工具执行失败
catch

    sink


    responseMap
            tc
}

失败的 ToolEnd 携带错误信息,同时这个错误结果也会被传递回 LLM(作为 ToolResponseMessage)。LLM 看到"工具执行失败"的反馈后,会在下一轮 React 循环中自主决定如何处理——可能是换一个工具、修改参数重试、或者直接告诉用户无法完成。 这就是 React 模式的核心优势:工具层面的错误不需要代码层面的重试逻辑,LLM 自己就能根据错误反馈做出决策。

两种错误处理的对比

维度 LLM 调用错误 工具执行错误
错误性质 网络/服务/Token 等模型基础设施问题 工具参数错误、文件不存在等业务问题
事件类型 Error ToolEnd
重试方式 代码层面自动重试( 依赖 React 循环,LLM 自主修复
前端展示 时间线中显示错误+重试状态 工具调用状态标记为完成
重试次数 maxRetries 由 LLM 决定,受

前端改造

为了配合 SkillsReactAgent 的分阶段流式输出,前端做了相应的改造。这里不深入前端代码细节,只讲改造的核心思路。

Timeline 时间线模型

之前的前端消息结构比较简单,只有 text(回答内容)和 thinking(思考过程)。改造后引入了 Timeline 时间线 模型,将思考过程、工具调用、错误信息统一到一条时间线中:

aiMsg = {
    content: '',          // 正式回答
    thinking: [],         // 思考内容片段
    timeline: [],         // 时间线(统一展示)
    reference: [],        // 参考来源
    recommend: [],        // 推荐问题
    showTimeline: true,   // 是否展开时间线
    hasThinking: false    // 是否有思考内容
};

Timeline 中的每一项对应一种事件类型: | 事件类型 | Timeline 条目 | 展示效果 | | --- | --- | --- | | thinking | {type: 'thinking', content: '...'} | 思考内容文本 | | tool_start | {type: 'tool', toolName: 'bash', status: 'running'} | 工具名 + spinner 动画 | | tool_end | 更新对应条目的 | 工具名 + 完成标记 | | error | {type: 'error', message: '...'} | 错误提示 |

前端收到 tool_end 事件时,通过 toolCallId 找到对应的 tool_start 条目,将状态从 running 更新为 completed。

事件分发

前端通过 processStreamData 函数按 type 字段分发事件:

const

        aiMsg


        aiMsg
        aiMsg


        aiMsg


        aiMsg


        aiMsg

}

Complete 事件的作用

当收到 Complete 事件时,前端会做状态清理。一个重要的细节:如果时间线中有 error 类型的条目,时间线保持展开;否则折叠。这样用户在出现错误时可以直接看到问题所在。

默认展示行为

  • 流式输出过程中:时间线默认展开,用户可以实时看到思考过程和工具调用
  • 输出完成后:如果没有错误,时间线自动折叠,只保留正式回答
  • 如果出错:时间线保持展开,方便用户排查问题

演示效果

正常使用一个skills的过程: 故意把大模型apikey写错,触发error:

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。