技术

Omnilude 的 ai-service 为什么采用 Assistant / Thread / Run 结构

AAnonymous
13分钟阅读

开始之前

这篇文章想分享一下 Omnilude 的 ai-service 是怎么实现的。有一句很多人都听过的名言:不要重复造轮子。对这句话,我当然也认同。我在设计 ai-service 的 AI 执行方式时,之前用过 OpenAI 的 Assistants API(Beta),那次体验并不差。再加上当时也没有更闪亮的灵感,所以最初的结构几乎是带着模仿意味借过来的。

OpenAI 的 Assistants API 结构其实相当简单。它有一个 assistant / thread / message / run / run step 的骨架,可以把 Assistant 理解成预设,把 Thread 理解成对话容器,把 Run 理解成真正的执行。OpenAI 官方文档里的 deep diveFAQ 也把这条概念主线讲得很清楚。

不过,Omnilude 并没有原样照搬这套结构。我们的服务并不是只围绕 OpenAI 运转的。我们需要在一个平台里同时处理本地模型、OpenAI 兼容服务器、Anthropic、Ollama、LM Studio 等多种执行路径。

所以,Omnilude 的 ai-service 虽然借用了 OpenAI Assistants 的概念,但真正的实现走向了更彻底的分离。在这篇文章里,我会尽量用简单的方式把这套结构讲清楚。

先看,它到底保存了什么

Omnilude 的 ai-service 里有不少和 AI 相关的对象。名字看起来有点复杂,但把角色拆开来看,其实比想象中简单。

  • AiProvider:保存使用的是哪一家提供方,比如 OpenAI、Anthropic、Ollama、LM Studio。
  • AiApiKey:保存连接该提供方所需的认证 key。
  • AiModel:保存具体模型信息,比如模型名、是否支持 reasoning、价格、上下文大小等。
  • AiAssistant:定义模型要用在什么目的上、遵循什么指令的执行预设。
  • AiThreadAiMessageAiRunAiRunStep:保存对话和执行记录。
  • AiAgent:把多个 assistant 组合成工作流的上层结构。

如果压缩成一句话,大概就是这样。

Provider 和 Model 是材料,Assistant 是配方,Thread 和 Run 是实际的下单与烹饪记录。

用图来看,这种关系会更容易理解。

这也是它和 OpenAI 过去的 Assistants API 相似的地方。Assistant、Thread、Message、Run、Run Step 这一套骨架本身是相近的。不同的是,Omnilude 把 provider / model / apiKey 放到了 Assistant 外面,作为独立资产来管理。

为什么要这样拆开

原因很简单。因为在 Omnilude 里,我不想把 Assistant 只看成一团 prompt。

假设我们要创建一个 assistant。真正想保存的,绝不只是 instructions

  • 使用哪家 provider
  • 使用哪个 model
  • 是否支持 reasoning
  • response format 是 text 还是 json
  • temperature 和 top-p 怎么设置
  • 将来就算切换模型,原本的基准模型是谁

看一眼真实的 AiAssistant 实体,就能发现这种思路被直接写进去了。modelIdprimaryModelId 是分开的,apiKeyIdprimaryApiKeyId 也是分开的。但这不仅仅是为了记录原始基准模型。更重要的原因是 fallback。当某个 provider 进入异常状态、某个 key 被封、或者当前连接的模型变得不稳定时,服务需要切到另一个模型和 key 上继续跑下去。也就是说,把当前使用的模型与 key 和原本作为主线的模型与 key 分开,才能在故障场景里维持更稳健的运行。

正因为有这一层设计,assistant 就不再只是一个 prompt 模板,而变成了一个可运营的执行预设

这也是 Omnilude 和 OpenAI Assistants 最大的差异之一。OpenAI 的 Assistant 更像是把 model + instructions + tools 打成一个包;而 Omnilude 则是先把 provider、model、api key 变成独立资产,再把 assistant 设计成建立在这些资产之上的组合层。

Assistant 不会停留在数据库实体这一层

这里还有一个重要步骤。存下来的 AiAssistant 并不会被直接执行。

真正执行之前,AiAssistantService 会一次性组装下面这些信息。

  • assistant 自身的 preset 值
  • 关联的 model 信息
  • 关联的 provider 信息
  • 加密保存的 api key

然后把这些结果转换成一个叫 RunnableAiAssistant 的执行对象。

这个名字其实很关键。在 Omnilude 里,数据库中保存的 assistant 和真正拿来执行的 assistant,是分开看的。

  • 数据库里的 AiAssistant:保存配置的实体
  • 执行时的 RunnableAiAssistant:可以直接拿去调用模型的对象

这样拆开的好处很明确。

第一,执行层不再需要关心 JPA 实体。 第二,不同 provider 之间的差异可以藏进 RunnableAiModel 的实现里。 第三,reasoning 选项、response format、sampling 参数都可以在执行时集中解释。

也就是说,Omnilude 把「把 assistant 当数据保存」和「真正把 assistant 跑起来」这两件事拆开了。

一次真实调用是怎么流动的

直接执行 assistant 的最简单路径,大致是这样的。

这条流程的关键点是 LlmGateway。在 Omnilude 里,如今大多数直接执行都会经过这个 gateway。

而且 gateway 做的事情并不简单。

  • 应用 provider 级别的 Rate Limit
  • 记录执行开始与结束
  • 创建并管理 AiRunAiRunStep 的状态
  • 记录请求与响应 payload
  • 收集 token 和成本信息

也就是说,它不是简单地调用 assistant.chat(),而是把这件事包装成一次带执行追踪的平台调用

这个差异很快就会变得重要。只要 AI 功能用得稍微深一点,最后更关键的往往不是「用了什么 prompt」,而是「哪个 assistant 用哪个模型、花了多少成本、产出了什么结果」。

为什么要保留 Thread、Message、Run

很多人读到这里时,大概都会产生一个疑问。

既然 assistant 本来就可以直接运行,为什么还要把 Thread、Message、Run 单独保留下来?

我认为这恰恰是 Omnilude 结构里非常重要的一部分。

Assistant 只是一个预设。但真实的用户体验不会停在预设这一层。用户会继续对话、不断累积消息、在某个时点发起执行请求,然后再把结果作为下一条消息收回来。

在 Omnilude 里,这条链路被拆成了下面这些部分。

  • AiThread:对话房间
  • AiMessage:用户和 assistant 之间来回交换的每一轮消息
  • AiRun:以某条消息为起点的执行请求
  • AiRunStep:该次执行内部的细节追踪

我觉得,这可以看作是把 OpenAI Assistants 曾经展示过的结构,较为现实地落到了产品运行时中。

比如客户端可以先创建一个 thread,再往里面放一条 user message,然后调用 /ai-runs。从这一刻开始,它就不再只是一次 chat completion 调用,而变成了一个带对话上下文的执行单元

这为什么重要?

第一,你可以把执行记录和对话绑定起来看。 第二,你可以管理同一个 thread 从哪里重新开始。 第三,你可以在 thread 层上附加标题生成、收藏、重置之类的功能。

越是把 AI 做成产品,这种接口往往越比单次调用活得更久。

但点下 Run,不一定只会跑一个 Assistant

这里 Omnilude 和 OpenAI Assistants 又出现了不同。

OpenAI Assistants 给人的印象比较明确:有 Assistant,有 Thread,而 Run 就是在这个 Thread 上执行对应的 assistant。

但在 Omnilude 里,/ai-runs 现在已经更像一个 workflow 的入口。真实实现中,AiRunController 不会立刻去调用模型,而是先把任务丢进 DTE 队列。接着 ChatAgentTaskHandler 会拿到这个任务,读取 thread 里的消息,然后执行 AiAgent 的 workflow。

也就是说,当前 Omnilude 里的 Run,已经不只是「调用一个 assistant」这么简单了。在某些场景下,它是一个触发多个 assistant 组成的 workflow 的起点

这样再看,结构就会更清楚。

  • Assistant:一个 LLM 预设
  • Agent:由多个 assistant 组合起来的工作流
  • Run:真正启动这个工作流的执行单位

正因为这一点,Omnilude 的 ai-service 比起单纯的 assistant 存储层,更像一个平台。

并不是所有路径都已经完全统一

这里还想补一个更现实的说明。即使整体结构已经很清楚,现在也不是所有入口都走完全相同的执行路径。

比如 backoffice playground 和 direct inference 这两类路径会经过 LlmGateway,因此执行追踪和成本记录会比较完整。相反,内部系统聊天 API /system/ai/chat 走的是更直接的一条路。它会先加载 assistant,再套上 rate limiter,然后直接调用 chat,所以和基于 gateway 的追踪路径相比,风格上还是有些不同。

我反而觉得这很正常。现实中的平台,很少一开始就围绕一个完美模式长出来。更常见的情况是,随着使用场景增加,它们慢慢向共同结构收敛。

关键在于,现在的中心轴已经立住了。

  • 管理对象已经拆开
  • 执行对象正在向 RunnableAiAssistant 统一
  • 对话与执行记录已经以 Thread / Message / Run / RunStep 留下
  • 上层 orchestration 已经交给 AiAgent

我很喜欢这套结构

重新看一遍,你会发现 Omnilude 的 ai-service 并不只是一个「很会调用模型的服务」。

它想同时做到四件事。

  • 在一个平台中处理多个 provider 和 model
  • 把 assistant 做成可复用的执行预设
  • 用 thread/message/run 结构保存对话和执行
  • 未来再进一步把 assistant 组合成 agent workflow

我觉得这条方向非常现实。只要你开始把 AI 放进产品里,最终真正需要的,不是几个调用模型的辅助函数,而是一个可以执行的接口

而这个接口,和 OpenAI Assistants 当年展示过的概念结构其实很像。只是 Omnilude 又往前走了一步,把 provider 和 model 拆得更清楚,把 tracking 和 workflow 更深地嵌进了设计里。

收尾

在 Omnilude 的 ai-service 中,AiAssistant 并不是一个单纯的 prompt 仓库。它更像一个执行预设,把要调用哪个 provider、哪个 model、用什么方式调用、以什么格式收回结果、又要落在什么运行时里记录下来,这些事情一起打包了。

AiThreadAiMessageAiRunAiRunStep 则是让这个预设能够在真实产品流程中运转起来的运行时接口。随着 AiAgent 叠加进来,Omnilude 的 ai-service 也不再只是管理几个 assistant,而是逐渐长成一个能够运行工作流的平台。

下一篇,我会顺着这个话题继续往下讲,带来 如何把 assistant 组合成 agent 的故事。