跳到主要内容

数据截至 (上游 commit 5053c08115bd)

Onyx — 架构与原理

30 秒导读: Onyx 是一套可自己部署的开源 AI 聊天平台——把 LLM 接到公司里的 Slack / Confluence / Google Drive 等几十个数据源上,做成一个带检索、工具和 Agent 的 ChatGPT 替代品。它值得读的地方不是"怎么调模型",而是上下文工程(每条消息该摆在提示词的哪个位置)和检索工程(一句提问怎么变成一段带引用的答案)。

1. 这是什么(零基础也能懂)

一句话定义。 Onyx 是一个自托管的 AI 聊天应用层:前面是聊天界面,后面接你自己选的 LLM,中间自带一整套"把公司资料抓下来、切块、向量化、可检索"的引擎。

解决什么问题。 假设你们公司的知识散在 Slack 讨论、Confluence 文档、Jira 工单、Google Drive 里。你想问一句"上个季度那个退款流程改动是谁定的、依据是什么",没有任何一个搜索框能回答——因为答案要跨四个系统拼起来,还得给出出处。Onyx 就是补这一层:它把这些源持续同步进自己的索引,再让 LLM 带着检索结果回答,并且每句话都能点回原文

给谁用。 想在内网跑一个"懂公司资料的 ChatGPT"的团队;以及想拿一个成熟的开源 RAG + Agent 栈当底座去改的工程师。

它能做什么。

能力白话
Agentic RAG检索不是一次性的:模型可以边搜边想,连搜几轮再回答
60+ 连接器Slack、Confluence、Drive、Jira、GitHub、Notion…… 持续增量同步
Web 搜索 + 抓网页内置爬虫,也可接 Serper / Brave / Firecrawl 等
代码执行在沙箱里跑 Python,画图、算数据、改文件
Deep Research多步研究流程,产出长报告
自定义 Agent / MCP自定义指令、知识范围和动作,动作可来自 MCP
引用答案里的 [[1]] 直接链回原文档

用起来什么样。 部署是一条命令(README 首屏):

curl -fsSL https://onyx.app/install_onyx.sh | bash

用起来就是打开网页聊天。要程序化调用的话,对外是一个 HTTP 接口 POST /chat/send-chat-message,stream=true 时返回 text/event-stream 的 NDJSON 包流,stream=false 时返回一个完整 JSON(backend/onyx/server/query_and_chat/chat_backend.py:771,send_chat_message)。

一句话直觉。 把 Onyx 想成**"公司内网版 ChatGPT + 一个专门喂它的私有搜索引擎"**:上半身是对话与工具调度,下半身是一条 7×24 跑着的数据管道,把散在各处的文档源源不断变成可检索的 chunk。

2. 顶层全景(它大概怎么转)

Onyx 的后端可以按"三条栈"来理解:对话栈负责一次问答的编排,检索栈负责把问题变成文档,索引栈负责把公司资料变成可检索的东西。前两条跑在 API 进程里(在线),第三条跑在 Celery 后台(离线)。

怎么读这张图: 从上往下是一次提问的流向;虚线是离线管道,和在线路径只通过向量库相接。

用户提问

┌────────────▼─────────────┐
│ ① 对话栈 onyx/chat/ │ 三层循环 + Emitter 流式推包
└──────┬───────────────┬───┘
│ 调工具 │ 边生成边推包
┌──────▼──────┐ └────────► 前端(NDJSON 包流)
│ ② 检索栈 │
│ 混合检索+合段│
└──────┬──────┘
│ 查
┌──────▼───────────────┐
│ 向量/关键词索引 │ OpenSearch 或 Vespa
└──────▲───────────────┘
┆ 写入(离线)
┌──────┴───────────────┐
│ ③ 索引栈 Celery 后台 │ 连接器 → 分块 → 嵌入
└──────────────────────┘

放大看对话栈,它是严格的三层,一层只干一件事:

process_message ── 校验、拼历史、装工具、开线程池、排空队列、落库
│ 每个候选模型一个 worker 线程

run_llm_loop ── 一个 turn:重拼上下文 → 调一次模型 → 跑工具 → 回到循环
│ 最多 MAX_LLM_CYCLES(默认 6)轮

run_llm_step ── 一次推理:把 token 流拆成 推理 / 答案 / 工具调用 三条


Emitter.emit(Packet) ──► merged_queue ──► 主线程 drain ──► 前端

部件一句话职责。

部件干什么在哪个文件
build_chat_turn校验请求、构建历史、准备文件与工具、造状态容器backend/onyx/chat/process_message.py:593
_run_models每个模型开一个 worker 线程,主线程排空共享队列并落库backend/onyx/chat/process_message.py:1143
Emitter底层不必逐层 yield,直接把 Packet 打上 model_index 丢进队列backend/onyx/chat/emitter.py:8
run_llm_loop一个 turn 的编排:每轮重拼上下文、跑工具、判断何时收尾backend/onyx/chat/llm_loop.py:743
construct_message_history按固定顺序拼消息、按 token 预算从头裁剪backend/onyx/chat/llm_loop.py:380
run_llm_step一次推理,把流式 delta 归类成推理 / 答案 / 工具调用backend/onyx/chat/llm_step.py:1575
run_tool_calls同名工具调用先合并,再线程池并行执行backend/onyx/tools/tool_runner.py:232
search_pipeline权限过滤 → 混合检索 → 相邻 chunk 合成 sectionbackend/onyx/context/search/pipeline.py:263
index_doc_batch一批文档的端到端索引(过滤 → 分块 → 嵌入 → 写入)backend/onyx/indexing/indexing_pipeline.py:1271

主线走一遍(不进代码)。

  1. 请求打到 /chat/send-chat-message,build_chat_turn 把这次对话需要的一切装配好:历史消息、上传文件、项目文件、可用工具、状态容器。
  2. _run_models 给每个候选模型开一个线程跑 run_llm_loop;主线程只做一件事——从共享队列里把包捞出来往下游吐,顺便每 50ms 查一次用户有没有点停止。
  3. run_llm_loop 进入 cycle:先重新拼装一遍完整上下文(系统提示、自定义 Agent 提示、项目文件、历史、最后一条用户消息、reminder),再调 run_llm_step 做一次推理。
  4. 模型如果给了工具调用,run_tool_calls 并行执行它们;检索类工具会走检索栈拿回文档,文档以 JSON 形式回填进历史,引用号同时登记进 DynamicCitationProcessor
  5. 只要模型这一轮没有再要工具,循环就结束;run_llm_loop 发一个 OverallStop 包,主线程把累积的状态写进数据库。

3. 阅读地图

六章按"从上到下、由浅入深"排。只想懂原理读 1–3;想改检索/索引读 4–5;想部署运维读 6。

顺序章节读完你会知道
1一次对话的生命周期:三层循环与流式协议三层各自的职责边界、Emitter 与状态容器为什么要分开、Packet/Placement 这套流式协议怎么让前端渲染出"分块"的界面、用户点停止时发生什么
2上下文工程:每条消息放在哪、为什么系统提示、自定义 Agent 提示、项目文件、上传文件、reminder 各自的位置与移动规则,以及每条规则背后的实验结论;token 预算怎么算、历史怎么裁、超长对话怎么压缩
3工具与子 agent:定义、并行执行、不听话模型的兜底Tool 抽象长什么样、并行工具调用的合并与引用号分段、模型把工具调用写进正文时怎么抠出来、Deep Research / Coding Agent 这类"子 agent"是怎么用假工具实现的
4检索栈:一句提问如何变成带引用的答案查询扩展与范围判定、ACL 过滤、混合检索(向量+关键词)的归一化坑、相邻 chunk 合段、引用号从生成到渲染的完整链路
5索引栈:60+ 连接器如何变成可检索的 chunk连接器的四种形态(Load / Poll / Slim / Checkpointed)、分块策略(mini chunk / large chunk / contextual RAG)、嵌入与写入、增量同步与剪枝
6运行时:Celery 工蜂群、Redis 协调、多租户与开源分层八个 Celery worker 各管哪些队列、primary 的单例锁与 beat 看门狗、多租户按 schema 隔离、MIT 与 ee/ 企业版的边界

4. 巧妙之处

这一节是"读完要带走的东西"——每条都是别处能抄的设计决策。

① Emitter:让"流式"不再污染调用栈。 一次对话有三层嵌套,工具里还能再嵌工具。如果每层都靠 yield 往上传,签名会全被生成器污染。Onyx 让最底层直接调 emitter.emit(packet) 把包丢进共享队列,主线程排空队列往外吐——中间层完全不用知道"流式"这回事(backend/onyx/chat/emitter.py:8,Emitter.emit)。同一个抽象还顺手解决了两件事:多模型对比时用 model_index 给包打标签共用一条队列;不需要流的调用方(搜索 API、MCP server)换成 NullEmitter 就地丢弃(backend/onyx/chat/emitter.py:43)。

② 每个 cycle 重新拼一次上下文,而不是往数组尾部 append。 这是 Onyx 上下文工程的地基。run_llm_loop 的每一轮都重新调用 construct_message_history,把系统提示、自定义 Agent 提示、项目文件、历史、reminder 按固定顺序重新排一遍(backend/onyx/chat/llm_loop.py:380)。代价是每轮多做一次拼装,换来的是"某条消息该在哪"变成一个可以随时调整的策略,而不是历史里的既成事实。

③ reminder 钉死在上下文最末尾。 项目自己的设计说明写得很直白:所有微调方式都让 LLM 对最靠近生成位置的 token 注意力最强,所以引用要求、"该调什么工具"这类必须被遵守的短指令一律作为最后一条消息追加(backend/onyx/chat/README.md「Reminders」节)。select_reminder_text 是这套规则的决策表:刚生成过图就换成收尾提醒;刚搜过网 open_url 工具确实在场才提示去打开链接——否则模型会去调一个不存在的工具,然后向用户漏出困惑的解释(backend/onyx/chat/llm_loop.py:715)。

④ 自定义 Agent 的指令不放系统提示,而是当成一条会移动的用户消息。 放系统提示里模型跟随得差,而且当自定义指令和系统提示正交甚至冲突时,弱模型会在工具调用和最终回答里产生怪异输出。Onyx 的做法是把它插在最后一条用户消息之前,并随对话推进一路往下移(backend/onyx/chat/llm_loop.py:380,construct_message_history 第 2 步)。只有当用户主动勾选"替换基础系统提示"时,它才真的变成系统消息并停止移动(backend/onyx/chat/llm_loop.py:743persona.replace_base_system_prompt 分支)。

⑤ 工具返回值在历史里被丢弃,工具参数留着。 一次内部检索的返回可能好几千 token,留在历史里几轮就把上下文吃光。Onyx 的取舍是:响应体换成一句"已不可用",但把查询词、参数这些信息密度极高、token 极少的部分完整保留,让模型知道自己搜过什么、别重复搜(backend/onyx/chat/README.md「Tool Calls」节)。带查询扩展的检索还有个细节——历史里给模型看的是扩展后的完整查询集,而不是它原本写的那一句。

⑥ 文档喂给模型的形态被刻意做"笨"。 检索结果不是原样塞进去,而是压成一个 JSON,每篇只留 document(一个纯数字)、titlemetadatacontents。字段叫 document 而不是 citation_id,是因为后者会诱发模型在推理里写出"我应该引用 citation_id: 5……"这类噪声;数字放在最前、长正文放在最后,是因为模型的局部注意力仍然比全局好(backend/onyx/chat/README.md「How documents are represented」节)。

⑦ 给不听话的模型留了一条兜底路径。 有些自托管模型没有原生 tool calling,或者会把工具调用当成正文吐出来。_try_fallback_tool_extraction 在三种情形下触发(强制要求工具却没给、只有推理没有答案也没有工具、正文里检测到 XML 风格的调用块),然后从答案 / 原始答案 / 推理里逐个尝试把 JSON 或 <invoke> 块抠出来匹配工具定义(backend/onyx/chat/llm_loop.py:214backend/onyx/chat/llm_step.py:425,extract_tool_calls_from_response_text)。关键在于一个 turn 只允许兜底一次——否则烂模型会把循环拖死。

⑧ 并行工具调用先合并、再给引用号分段。 模型常常一口气发出三次 internal_search_merge_tool_calls 会把同名可合并工具的 queries / urls 拼成一次调用再跑(backend/onyx/tools/tool_runner.py:63)。真要并行时,每个工具调用拿到的起始引用号相隔 100,这样并发写引用表不会撞号(backend/onyx/tools/tool_runner.py:232 的文档串)。

⑨ token 预算故意留 5% 余量。 本地用 tiktoken 估算的 token 数会比供应商真实分词器少,直接顶着 max_input_tokens 拼上下文会溢出。Onyx 统一按 max_input_tokens * (1 - 0.05) 算可用额度(backend/onyx/configs/model_configs.py:73,GEN_AI_INPUT_TOKEN_SAFETY_MARGIN)。

⑩ 检索侧承认了搜索引擎的局限,把打分修正搬回代码里。 项目在 OpenSearch 的说明里推演得很细:嵌入分数聚在 0.6–0.8 这种窄带里,归一化前做加/乘性 boost 会把好结果直接打到最差档;而 OpenSearch 的归一化处理器又跑在最后一步,时间衰减这类信号既无法排序也无法好好归一化。结论是不在查询里做时间加权,让 OpenSearch 只负责初筛,时间衰减和 boost 回到 Python 里做(backend/onyx/document_index/opensearch/README.md)。

⑪ 分块给检索和给阅读分成两套。 同一份文档会被切成三种粒度:正常 chunk 用于主检索,mini chunk(150 token)提高向量召回精度,large chunk 把连续 4 个小块并成一个大块用于需要上下文的场景(backend/onyx/indexing/chunker.py:111,generate_large_chunks;比例常量 LARGE_CHUNK_RATIO = 4backend/onyx/configs/app_configs.py:1482)。检索完再由 merge_individual_chunks 把命中的相邻 chunk 重新粘回连续 section 交给模型(backend/onyx/context/search/pipeline.py:145)。

⑫ 历史压缩挂在消息树上,而不是原地改写。 长对话要压缩,但 Onyx 支持分支(同一条消息可以有多个后续)。它的做法是把摘要新建成一条 ChatMessage,挂在触发压缩时的分支末端,再用 last_summarized_message_id 反向指到截断点。这样摘要只对产生它的那条分支生效,不会把"后来才发生的上下文"泄漏到兄弟分支里(backend/onyx/chat/COMPRESSION.md)。

5. 代码地图(导航索引)

按符号名 grep 比按行号更抗上游漂移;行号 as-of sourceCommit

主题文件路径:行符号名
对话入口(HTTP)backend/onyx/server/query_and_chat/chat_backend.py:771send_chat_message
顶层装配backend/onyx/chat/process_message.py:593build_chat_turn
多模型并行 + 排空队列 + 落库backend/onyx/chat/process_message.py:1143_run_models
流式包投递backend/onyx/chat/emitter.py:8 / :43Emitter / NullEmitter
包坐标(前端渲染路由)backend/onyx/server/query_and_chat/placement.pyPlacement
包类型全集backend/onyx/server/query_and_chat/streaming_models.py:490Packet
turn 编排循环backend/onyx/chat/llm_loop.py:743run_llm_loop
上下文拼装 + 预算裁剪backend/onyx/chat/llm_loop.py:380construct_message_history
reminder 决策表backend/onyx/chat/llm_loop.py:715select_reminder_text
reminder 文本组装backend/onyx/chat/prompt_utils.py:127build_reminder_message
系统提示动态拼装backend/onyx/chat/prompt_utils.py:226build_system_prompt
单次推理backend/onyx/chat/llm_step.py:1575 / :1037run_llm_step / run_llm_step_pkt_generator
历史 → LLM 格式backend/onyx/chat/llm_step.py:854translate_history_to_llm_format
工具调用兜底解析backend/onyx/chat/llm_loop.py:214, backend/onyx/chat/llm_step.py:425_try_fallback_tool_extraction, extract_tool_calls_from_response_text
状态累积容器backend/onyx/chat/chat_state.py:34ChatStateContainer
引用处理backend/onyx/chat/citation_processor.py:69DynamicCitationProcessor
历史压缩backend/onyx/chat/compression.py, backend/onyx/chat/COMPRESSION.md
循环上限 / token 余量backend/onyx/configs/chat_configs.py:12, backend/onyx/configs/model_configs.py:73MAX_LLM_CYCLES(6), GEN_AI_INPUT_TOKEN_SAFETY_MARGIN(0.05)
工具抽象backend/onyx/tools/interface.py:15Tool
内置工具注册表backend/onyx/tools/built_in_tools.py:35 / :48 / :49BUILT_IN_TOOL_MAP, STOPPING_TOOLS_NAMES, CITEABLE_TOOLS_NAMES
并行执行 + 合并backend/onyx/tools/tool_runner.py:232 / :59 / :114run_tool_calls, _merge_tool_calls, _safe_run_single_tool
内部检索工具backend/onyx/tools/tool_implementations/search/search_tool.py:270 / :584SearchTool, _expand_queries_and_decide_scope
Deep Research 子循环backend/onyx/deep_research/dr_loop.py:200run_deep_research_llm_loop
检索主管线backend/onyx/context/search/pipeline.py:263 / :36 / :113search_pipeline, _build_index_filters, merge_individual_chunks
混合检索执行backend/onyx/context/search/retrieval/search_runner.py:89 / :28search_chunks, combine_retrieval_results
向量库后端backend/onyx/document_index/opensearch/, backend/onyx/document_index/vespa/OpenSearchDocumentIndex, VespaIndex
检索打分取舍(设计说明)backend/onyx/document_index/opensearch/README.md
分块backend/onyx/indexing/chunker.py:124 / :107Chunker, generate_large_chunks
索引主管线backend/onyx/indexing/indexing_pipeline.py:1271 / :280 / :953index_doc_batch, embed_and_stream, add_contextual_summaries
连接器接口backend/onyx/connectors/interfaces.py:120 / :125 / :134 / :266LoadConnector, PollConnector, SlimConnector, CheckpointedConnector
连接器注册表backend/onyx/connectors/registry.py:14CONNECTOR_CLASS_MAP(53 个来源)
后台 worker 编排backend/supervisord.conf:30:126, backend/onyx/background/README.mdcelery_worker_primarycelery_beat
多租户上下文backend/shared_configs/contextvars.py:8, backend/shared_configs/configs.py:194CURRENT_TENANT_ID_CONTEXTVAR, MULTI_TENANT
开源 / 企业版边界LICENSE, backend/ee/, web/src/ee/