在之前的课程中,我们的各个 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:
