技术

Omnilude 的 ai-service 中,Agent 是这样运行的

AAnonymous
12分钟阅读

开始之前

这篇文章是我之前写的 Omnilude 的 ai-service 为什么采用 Assistant / Thread / Run 结构 的下一篇。上一篇主要讲了 assistantthreadrun 分别扮演什么角色,这次我想继续往下讲,说明 agent 在这套结构之上究竟是怎么运行的。

在解释这套结构时,我想先去掉一个误解。Omnilude 的 AiAgent 并不是什么巨大的魔法黑盒。它也不是把 assistant 变成另一种东西的额外层。恰恰相反,AiAgent 更接近一种把多个 assistant 按节点组织起来,并把执行顺序保存为 workflow 的结构。

换一种更容易理解的说法:

如果 AiAssistant 是单个 LLM 的预设,那么 AiAgent 就是把这些预设连接起来的工作流。

这篇文章会尽量用不难懂的方式,说明这条执行链在代码里是如何实现的。

Agent 不是另一个 AI,而是 workflow

首先要看的就是 AiAgent 实体。这里面当然也有名称、描述、类型,但真正的核心是 workflow 字段。这个值不是存在独立表里,而是以 JSONB 的形式保存。

也就是说,在 Omnilude 里,agent 与其说是「一个更聪明地调用模型的对象」,不如说更接近一个由节点和边表示的执行图

例如,一个 agent 可以长这样:

  • 从起始节点接收输入。
  • generate-text 节点里用一个 assistant 生成文本。
  • router 节点里决定下一条路径。
  • 最后由 finish 节点整理结果。

看到这里,assistant 和 agent 的区别就会清晰很多。

  • assistant 是一次推理用的预设。
  • agent 是决定这些推理按什么顺序、在什么条件下连接起来的 workflow。

Agent 不是另一个 AI,而是 workflow(文本参考)

AiAssistant
  - 单次 LLM 调用的预设
  - 定义要使用哪个 model/provider/instructions

AiAgent
  - 连接多个节点的 workflow
  - 每个节点都可以使用 assistant,也可以像 tool 一样工作
  - 关键不在于「一次调用」,而在于「执行顺序和连接关系」

请求不会立刻执行

在 Omnilude 中,AiAgent 的执行大多数时候不会以一次同步函数调用结束。这里真正重要的角色是 DTE(Distributed Task Executor)

整体流程大致如下:

  1. 客户端或后台发起 agent 执行请求。
  2. 服务端不会立刻处理,而是把它转换成一个 DTE job。
  3. WorkflowTaskHandler 从队列中取出这个 job。
  4. 接着由 SingleWorkflowExecutor 负责真正的 workflow 执行。
  5. 在内部,BasicWorkflowEngine 会逐个推进节点。

这套结构重要的原因其实很简单。agent 执行往往比想象中更长,可能带流式输出,需要记录中间状态,有时还必须放到后台跑。如果把这些都硬塞进普通的 request-response 里,稳定性会很差。把执行本身当成 job 来管理,反而更合适。

请求不会立刻执行(文本参考)

Controller
  -> AiAgentExecutor
  -> DistributedTaskQueue
  -> WorkflowTaskHandler
  -> SingleWorkflowExecutor
  -> BasicWorkflowEngine

关键点
  - 执行被当成 job 来处理
  - 流式输出和后台处理会更容易
  - 长时间执行也能在同一套结构里管理

起点只有一个 start node

有 workflow 并不意味着引擎会从头到尾按列表顺序遍历。真实的 BasicWorkflowEngine 会先找到 startNode=true 且类型以 -input 结尾的节点,然后把初始输入注入进去,从这里开始执行。

我很喜欢这一点。因为它并不是把 agent 看成「一条命令」,而是看成一个入口明确的执行图

比如 text-input 节点会先准备用户输入的字符串,并把这个值传给下一个节点。接着 generate-text 节点收到这个值,触发一次 LLM 调用。之后 output 会沿着 edge 继续传给下一个节点。

换句话说,agent 不是一个巨大的函数,而是一串由小执行单元组成的流程。

真正执行节点的是 NodeExecutor

这里最关键的一层就是 NodeExecutor。每个节点都有自己的类型字符串,NodeExecutorFactory 会根据这个类型找到对应的执行器。同时,NodeExecutorRegistry 会扫描 @NodeType 注解,自动注册「哪个执行器处理哪个类型」。

正因为有这层结构,新增节点时不需要重写整个引擎。只要实现一个节点、声明它的类型、挂上 provider,它就能进入运行时。

这篇文章里最值得先理解的代表性节点有四个。

  • TextInputNode: 准备起始输入。
  • GenerateTextNode: 使用 assistant 生成实际文本。
  • RouterNode: 用 LLM 判断下一条路径。
  • FinishNode: 汇总最终结果并结束执行。

只要理解这四个节点,就已经能大致看懂 Omnilude 的 agent 结构是怎么运转的。

即使在 Agent 内部,真正的推理仍然由 assistant 完成

这里就和上一篇文章重新连起来了。

即便有了 AiAgent,也不是 agent 自己直接去调用模型。真正的 LLM 调用发生在节点内部,而在那个时刻,AiAssistantService 会再次出现。

例如 GenerateTextNode 会从配置中读取 aiAssistantId。然后它通过这个 id 找到 assistant,再把 AiModelAiProviderAiApiKey 组合起来,构造成一个 RunnableAiAssistant。到这里之后,真正的 chat model 调用才会发生。

所以,理解这套结构最自然的方式是:

  • assistant 是推理用的组件。
  • agent 是决定这些组件按什么顺序运行的 workflow。

一旦理解了这一点,也就不需要再把 assistantagent 看成彼此竞争的概念。它们不是替代关系,而是层级关系。

即使在 Agent 内部,真正的推理仍然由 assistant 完成(文本参考)

AiAgent
  - 拥有 workflow 的上层执行单元

Node
  - 在需要时选择并调用 assistant

RunnableAiAssistant
  - 由 assistant + model + provider + key 组合成的真实执行对象

/ai-runs 路径上,agent 是叠在对话上下文之上的

还有一个很有意思的点是 /ai-runs 这条路径。它并不是一个单纯把已保存的 agent 直接执行掉的 API。它会先读取 thread 中的 message,再基于这些上下文去执行某个特定的 AiAgent workflow。

在当前实现里,ChatAgentTaskHandler 会挑选像 AiAgentType.USING_WEB_SEARCH_TOOL 这样的 agent 类型,并把它交给 SingleWorkflowExecutor。也就是说,在这个阶段,run 已经不只是一次简单的 LLM 调用,而是一个在对话上下文之上启动 agent workflow 的命令

这也是 Omnilude 的 ai-service 看起来更像平台而不是轻量模型封装的原因之一。同样的推理结构,一旦放到 thread/message/run 这套接口之上,它的产品意义就完全不同了。

我刻意没有把 multiagent 放进这篇文章

继续往下读代码时,你也会看到 multiagent 包。但它和这里正在说明的 AiAgent workflow 方向并不完全一样。它更像是一个单独的编排层,用来处理多 agent 协作、动态路由、重试、人类审核这类更大的问题。

更重要的是,它现在还有一些进行中的部分,所以我并不想把它和当前 AiAgent runtime 放在完全同一层来解释。因此这篇文章里我刻意把它拿掉了。这里的范围始终只聚焦于 如何通过组合 assistants 来运行一个 agent workflow

范围收窄之后,文章会更容易理解,说明也不会发散。

我认为这套结构相当现实

重新看一遍就会发现,Omnilude 的 ai-service 里的 agent 并不是什么名字很大的另一种 AI,也不是套在 assistant 外面的又一层 wrapper。相反,它其实更务实。

  • 执行流程保存在 AiAgent.workflow
  • 实际执行通过 DTE job 交出去
  • 引擎从起始节点开始沿着 edge 前进
  • 每个节点在需要时调用 assistant 来使用 LLM

我觉得这套结构好用,是因为它没有夸大扩展性。assistant、thread、run、agent 各自负责不同的角色,而且各自的边界相对清晰。

我越来越觉得,当 AI 功能真正进入产品之后,这种结构才是更重要的部分。比起「把模型调用好一次」,更能留下来的其实是:以什么单位保存、通过什么接口运行、又通过什么流程连接起来。

结尾

上一篇文章里,assistant / thread / run 是构成 Omnilude 执行平台的基础概念。这一篇里的 agent 则是叠在其上的组合层。AiAgent 不是另一个 AI,而是一个保存 workflow 的实体,它的执行会经过 DTE -> WorkflowExecutor -> NodeExecutor 这条路径。

而真正的推理依然由 assistant 完成。归根结底,Omnilude 的 ai-service 并不是让 assistant 和 agent 对立起来,而是通过把 assistant 当成零件来组装 agent 的方式不断扩展。

下一篇我会再往实战走一步,用更具体的例子来说明,我究竟是怎么设计实际的 workflow JSON 和节点结构的。