本文是 事件驱动的 ASR → LLM → ES 回写:设计拆解与实践 系列的第二篇,重点讲解 LLM 分析的阶段编排与事件驱动设计。

当一个系统里需要同时完成摘要、场景识别、质检分析和字段提取时,最容易出问题的地方不是模型本身,而是流程怎么组织。

这篇文章从学习者视角拆一个核心问题:为什么要把 LLM 分析拆成多个阶段,并且用事件把每一段结果串起来。

先把”一个大问题”拆成多个小问题

如果把所有分析都塞进一次调用里,问题会很多:

  • 输出很难稳定
  • 失败后难以重试局部步骤
  • 不同分析任务之间会互相干扰
  • 后续回写也不好分字段处理

更好的做法,是把任务拆开:

  • 先做摘要
  • 再做场景识别
  • 再做质检判断
  • 最后做字段提取

每个阶段都只回答一个问题,这样 LLM 的输出会更清晰,后面的结果也更容易落地。

为什么要有阶段编排

阶段编排的本质,是让复杂流程变得可控。

如果没有编排,系统会变成这样:

  • 入口调用一大堆逻辑
  • 中间结果乱放
  • 哪一步失败了很难定位
  • 后续处理分不清先后关系

有了编排之后,流程就会变得更像一条工厂流水线:

  1. 准备输入
  2. 执行第一阶段
  3. 保存第一阶段结果
  4. 触发下一阶段
  5. 重复直到全部完成

这样每个阶段都能单独观测、单独重试、单独优化。

事件为什么比直接调用更适合这里

如果阶段之间直接方法调用,表面上简单,实际上耦合会很重。

用事件来连接各阶段,有几个明显好处:

  • 事件处理器之间互不依赖
  • 每个阶段只关心自己的结果
  • 增加新阶段时,不需要重写老代码
  • 失败和重试可以按事件粒度处理

从学习角度看,可以把事件理解成”阶段完成后的通知”。

比如一个阶段结束后,不是直接去改所有地方,而是发出一个事件:

  • 告诉系统这个阶段已经完成
  • 携带本阶段的结果
  • 让对应的处理器去做回写或转换

这样设计后,主流程不会越来越长,反而可以越来越清楚。

结果怎么在阶段之间传递

阶段之间通常会共享两类信息:

  • 当前对话或任务本身
  • 当前阶段已经产出的结果

这类信息不需要到处散落,最好放进一个统一的中间载体里,由编排器维护。

你可以把它理解成一个”阶段共享对象”:

  • 前一个阶段写入结果
  • 后一个阶段读取结果
  • 所有阶段都围绕同一个任务 ID 运转

这样做的关键,不是术语,而是”把状态放在一个地方管住”。

统一调用模型客户端

LLM 调用通常还会碰到这些工程问题:

  • 模型要不要切换
  • 温度参数怎么控制
  • 返回内容怎么校验
  • 是否需要过滤调试输出

所以比较稳妥的做法,是把模型调用封装成统一客户端,而不是散落在各处。

好处很直观:

  • 参数统一管理
  • 错误处理统一管理
  • 返回值统一处理
  • 以后换模型更轻松

学习时可以重点记住:调用模型只是一步,真正难的是把它放进一条可维护的流程里。

再往深一层看:编排器、阶段和事件各自负责什么

如果把这条链路拆开,可以看到四个角色:

编排器

编排器负责决定”先做什么、后做什么、失败怎么办”。它像指挥员,不直接做分析细节,但决定分析顺序。

阶段函数

阶段函数只负责自己的小任务,例如摘要、场景识别、质检、字段提取。它们应该尽量单一、明确、可替换。

事件

事件负责把阶段完成这件事通知给后续处理器。事件里最好只放必要信息:任务 ID、阶段名、阶段结果、必要上下文。

处理器

处理器接到事件后,负责把结果落到正确的地方,比如写回索引、更新状态、或者触发下一步动作。

这样分层后,一个阶段出现问题,不会把整条链路拖垮。

伪代码:把整个编排过程串起来

下面这段伪代码更接近真实工程里的组织方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function orchestrate(dialogue):
context = new SharedContext(dialogue)

try:
context.status = "ANALYZING"

summary = summaryStage.run(context)
context.put("summary", summary)
eventBus.publish("SUMMARY_DONE", context)

scene = sceneStage.run(context)
context.put("scene", scene)
eventBus.publish("SCENE_DONE", context)

term = termStage.run(context)
context.put("term", term)
eventBus.publish("TERM_DONE", context)

field = fieldStage.run(context)
context.put("field", field)
eventBus.publish("FIELD_DONE", context)

context.status = "ANALYZED"
except Exception e:
context.status = "FAILED"
context.error = e
eventBus.publish("ANALYZE_FAILED", context)

return context

这段伪代码里最重要的不是语法,而是流程含义:

  • 先改状态
  • 再按阶段执行
  • 每个阶段结束都发事件
  • 失败时统一切换失败态

为什么顺序和幂等要一起考虑

阶段编排里最容易被忽略的,其实是顺序和幂等问题。

顺序

摘要可能影响场景识别,场景识别可能影响质检判断,质检判断又可能影响字段提取。顺序一旦错了,后续结果就会不稳定。

幂等

事件可能重复触发,任务也可能被重试。因此每个处理器都应该能识别”这个结果我是不是已经处理过了”。

学习时你可以把这件事记成一句话:

编排器负责顺序,事件处理器负责幂等。

这一篇真正要学会的东西

这一类设计最核心的不是”用了多少模型”,而是”流程有没有被管住”。

你应该重点建立这几个概念:

  • 把复杂任务拆成多个阶段
  • 每个阶段只做一件事
  • 用事件代替紧耦合的直接调用
  • 用统一中间状态承接阶段结果
  • 用统一客户端封装模型调用

下一篇就可以更自然地理解:结果写回 ES 为什么要单独设计,状态流转为什么要和补偿放在一起。详见 分析结果如何可靠回写 ES:状态流转与失败补偿