跳到主要内容

数据截至 (上游 commit 5053c08115bd)

上下文工程:每条消息放在哪、为什么

30 秒导读: 大多数人调 prompt 是改字。Onyx 花更多力气改的是位置——同一句"需要更多信息时可以继续调工具",写在系统提示的开头模型经常无视,挪进系统提示的 Tools 小节就几乎每次都听。本章讲 Onyx 怎么把这件事工程化:消息的三层表示、每轮重新装配的顺序规则、token 预算下的截断与降级、以及长会话的压缩。

本章只讲"上下文怎么拼"。循环本身怎么转、包怎么流看 01-chat-turn-loop.md;工具定义与子 agent 看 03-tools-and-subagents.md;检索结果怎么来、引用怎么落地看 04-retrieval-and-citations.md


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

一句话定义: 上下文工程 = 在把一堆材料(系统提示、历史对话、上传文件、项目文件、工具返回、提醒)塞进模型那有限的输入窗口时,决定留哪些、按什么顺序摆、摆在哪一格

为什么它比改措辞更值钱。 Onyx 自己的工程笔记记了一次实验结论:同一条"如果发现需要更多信息,鼓励你继续调用工具"的指令,放在系统提示的 Tools 小节里,所有模型都照做;把它挪到提示开头、哪怕只隔一段话,就经常被忽略——遵从率从 90% 掉到 30%(依据:backend/onyx/chat/README.md 的 "Reasons / Experiments" 一节)。

注:README 里的这些实验结论是线索,不是本章的事实来源。下文所有断言以源码为准,凡代码与 README 说法不一致的地方,本章会明确指出。

它要解决的具体麻烦,一共四类:

麻烦具体表现
窗口装不下项目里 50 个文件 + 20 轮历史 + 检索返回,随便就超 128k
位置决定注意力越靠近生成位置的 token,模型越"当真";中间的容易被略过
供应商不一样Ollama 不吃标准 tool 消息,Azure 有单请求图片数上限
分支与编辑用户能编辑历史消息拉出新分支,压缩摘要不能串到别的分支去

一句话直觉: 把上下文窗口当成一张从上往下排的座位表。系统提示坐第一排(固定),最后一排(最贴近模型开口的地方)永远留给"临出门前最后叮嘱的那句话"。中间的座位按"这材料还要用多久"分配:项目文件跟着你一路往后挪,随手上传的文件就钉在当初那一排、随着对话往前飘远。


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

怎么读这张图: 从左到右是一条消息的三次形变。左边是数据库里的树,中间是 Onyx 内部的规范表示,右边是真正发给 LLM 的那个 list。每个 LLM cycle 都会把中间到右边这一段重跑一遍

┌───────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ ① 库里的树 │ │ ② 规范表示 │ │ ③ 喂模型的 list │
│ ChatMessage │─────▶│ ChatMessageSimple │─────▶│ LanguageModelInput │
│ + ToolCall │ 转换 │ (带 token_count/ │ 装配 │ (system/user/ │
│ 空根+编辑分支 │ │ file_id/should_cache)│ 翻译 │ assistant/tool) │
└───────────────┘ └──────────────────────┘ └────────────────────┘
持久层 业务层 接口层
convert_chat_history construct_message_history translate_history_to_llm_format

三层各管什么:

类型谁在用职责
持久层ChatMessage / ToolCallDB 与 API存树、存分支、存工具调用的父子关系
业务层ChatMessageSimple整个 chat 流程唯一"富表示",带 token 数、缓存标记、文件溯源标记
接口层LanguageModelInputLLM 适配器故意做得极简,只有 OpenAI 四种 role

层与层之间是单向的:DB 对象在 process_message 里就被转成 ChatMessageSimple绝不往深处传(依据:backend/onyx/chat/README.md "Things to know",代码上体现在 backend/onyx/chat/process_message.py:982 调用 convert_chat_history 之后,run_llm_loop 只收 simple_chat_history)。

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

  1. process_message 读出分支上的消息链,先按摘要截断,再转成 ChatMessageSimple 列表。
  2. run_llm_loop。每个 cycle 开头,根据"这轮有哪些工具可用、上一轮跑了什么"重新生成系统提示和 reminder。
  3. construct_message_history 按固定顺序拼装,超预算就从最老的开始丢。
  4. translate_history_to_llm_format 翻成供应商能吃的格式,处理图片、缓存、Ollama 特例。
  5. 工具跑完,结果原样追加进 simple_chat_history,回到第 2 步。
  6. 整轮结束落库;下次会话再加载时,工具返回被换成一句占位串。

3. 三种消息表示的分层

3.1 ChatMessageSimple:唯一的富表示

这是整个 chat 流程的通用货币。字段不多,但每个都是为"装配"服务的(backend/onyx/chat/models.py:159ChatMessageSimple):

字段作用
token_count装配时算预算用;不是每次现算,能预存就预存
message_type除标准四种外,多一个 USER_REMINDERbackend/onyx/configs/constants.py:383
image_files只有 USER 消息有;图片以"定点"方式挂在当时那条用户消息上
tool_callsASSISTANT 消息带的并行工具调用数组(ToolCallSimplemodels.py:134
should_cache标出"可缓存前缀"到哪为止,供 prompt caching 切分
file_id文件溯源标记:这条消息是哪个文件注入进来的

file_id 是本章最巧的一个字段。文件被注入成独立的用户消息(backend/onyx/chat/chat_utils.py:98build_file_context),每条都打上 file_id。等截断把老消息丢掉之后,系统只要比对"还活着的 file_id" 和 "曾经注入过的全集",就知道哪些文件被模型忘了,然后给它一份 read_file 清单当补偿。全集存在 ChatHistoryResult.all_injected_file_metadata 里(models.py:189)。

配套的两个小模型:

  • ChatLoadedFilemodels.py:98):InMemoryChatFile 的子类,多带 content_texttoken_countlazy_loaded 构造器让文件字节首次访问才加载,token 数和纯文本则提前塞好(models.py:106-131)。
  • FileToolMetadatamodels.py:176):只有 file_id / filename / approx_char_count 三个字段——这是"文件塞不下时的降级形态"。

ExtractedContextFilesmodels.py:203)是项目/persona 文件加载的结果,一个对象同时表达三种归宿:装得下就进 file_texts,图片进 image_files,装不下就要么 use_as_search_filter=True(转去走检索),要么进 file_metadata_for_tool(转去走 read_file)。

3.2 DB 侧:一棵带空根的树

数据库这层不是列表,是backend/onyx/db/models.py:3252ChatMessage)。第一条消息是内容为空的根节点,纯粹是为了让"编辑第一条用户消息"也能拉出分支(源码注释就写在类 docstring 里,db/models.py:3253-3255)。

[空根消息] ← 没有内容,只为让首条也能被编辑
/ | \
[首条] [首条·编辑1] [首条·编辑2]
| |
[第二条] [编辑1 分支的第二条]

关键字段三个:

字段位置干什么
parent_message_iddb/models.py:3278挂在树上;分支就是同一个 parent 下的多个 child
latest_child_message_iddb/models.py:3283只记最新的那条,因为通常只需要复原"当前分支"
last_summarized_message_iddb/models.py:3289只有摘要消息才有,指向被压缩掉的最后一条

工具调用另存一张表(db/models.py:3395ToolCall),它用两个 parent 指针同时表达三件事:

  • parent_chat_message_id 非空 → 这是 LLM 直接发起的顶层工具调用。
  • parent_tool_call_id 非空 → 这是子 agent 内部的调用;父调用就是那个 agent 调用本身(db/models.py:3412-3414)。
  • turn_number 相同且父相同 → 并行调用;不同 → 顺序调用(db/models.py:3417)。

也就是说,"子 agent"在存储层没有专门的表,就是一串 parent_tool_call_id 指回 agent 调用的 ToolCall(依据:backend/onyx/db/README.md 关于 ToolCall 的说明段)。

3.3 LanguageModelInput:故意做窄

最终喂模型的类型简单到只有一行:

# backend/onyx/llm/models.py:127
LanguageModelInput = list[ChatCompletionMessage] | ChatCompletionMessage

ChatCompletionMessageSystemMessage | UserMessage | AssistantMessage | ToolMessage 的联合(backend/onyx/llm/models.py:225)。故意窄——USER_REMINDERfile_idshould_cache 这些 Onyx 概念一个都不许漏到这一层,全部在翻译时消化掉。这样加一个新 LLM provider 只需要面对四种 role。


4. 拼装顺序:谁定点、谁漂移

4.1 要解决的小问题

同一份材料,"这轮之后还有没有用"决定了它该怎么摆:

  • 项目文件:整个会话都重要,用户既然建了项目就是打算一直用它 → 必须一直待在离生成位置近的地方。
  • 随手上传的文件:只对当时那句提问重要 → 钉在原地,随着对话自然飘远、被淡忘也没关系。
  • reminder:只对"下一句输出"重要 → 永远贴在最末。

如果所有文件都往后挪,多个文件叠起来会把真正的用户问题挤到很远的地方,反而变差(依据:chat/README.md "Product considerations")。

4.2 装配顺序(这是本章的核心)

construct_message_historybackend/onyx/chat/llm_loop.py:380)产出的顺序是硬编码的七段,源码里连注释都写着这是"按 README 的顺序"(llm_loop.py:587-614):

[system] ← 固定第一,should_cache = True
[history_before_last_user] ← 截断过的老历史,从最老的开始丢
[custom_agent] ← 自定义 agent 提示,作为 USER 消息,随对话往后移
[project / context files] ← 项目文件,随对话往后移
[forgotten_files] ← 被丢掉的文件降级成 read_file 清单
[last_user_message] ← 本轮提问(图片挂在这上面)
[messages_after_last_user] ← 本轮已发生的 tool call / tool response
[reminder] ← 永远最后一条

注意 custom agent 提示不在 system 里。它被做成一条用户消息放在最后一句提问之前,并且随着对话往后移(llm_loop.py:596-597)。README 记录的理由是:塞进系统提示时遵从很差,而且当它和系统提示正交甚至矛盾时会拖垮整体表现,弱模型还会在工具调用里产生怪异产物。只有当用户勾选"完全替换系统提示"时,它才变成真正的 MessageType.SYSTEM 且不再移动(llm_loop.py:905-926)。

两种文件的差别在代码里就是两个函数:

项目 / persona 文件用户随手上传的文件
构造_build_project_messagellm_loop.py:352build_file_contextchat_utils.py:68
何时构造每个 cycle 重新拼,插在最后一句提问之前转换历史时一次性插在它当初那条用户消息之前(chat_utils.py:689-702
位置行为跟着对话尾巴走钉死不动,随历史变老
装不下时转检索或转 read_file 清单被截断丢掉,转 read_file 清单

图片走第三条路:它们不是独立消息,而是附在用户消息上image_files。项目里的图片会被追加到最后一条用户消息上(chat_utils.py:712-714),而且这些上下文图片的 token 刻意不计入该消息的 token 数(同处注释明说)。

4.3 预算怎么算、从哪开始丢

预算是自顶向下扣的(llm_loop.py:431-440):

history_token_budget = available_tokens
− system_prompt
− custom_agent_prompt
− project_messages
− reminder
(扣完为负数直接抛 ValueError)

然后先锁死不可丢的部分:最后一条用户消息 + 它之后的所有工具消息。这部分装不下就直接报错,不做静默降级(llm_loop.py:500-506)。剩下的额度才轮到老历史,从最新往回加、加不下就停:

# backend/onyx/chat/llm_loop.py:412-420(真实源码,节选)
for msg in reversed(history_before_last_user):
if current_token_count + msg.token_count <= remaining_budget:
msg.should_cache = True
truncated_history_before.insert(0, msg)
current_token_count += msg.token_count
else:
break # 这条以及更老的全部丢掉

重点看 break 而不是 continue:它不做"跳过大消息、捡小消息"的挑拣,一旦装不下就整段截断。好处是历史保持连续、前缀稳定,prompt cache 才有意义(should_cache 也是在这个循环里被打上的)。

4.4 丢掉的文件降级成一张清单

丢消息容易,丢文件麻烦——用户会以为模型还记得。Onyx 的处理是把丢掉的文件换成一张目录

老历史里有 3 个文件消息

│ 截断:文件 A、B 被丢

收集 dropped_file_ids ────┐
├──▶ _create_file_tool_metadata_message
摘要阶段就没进来的文件 ────┘ │
(orphaned metadata) ▼
"You have access to the following files.
Use the read_file tool ...
- file_id=\"...\" filename=\"a.pdf\" (~120,000 chars)"


插在最后一句提问之前(第 4 段)

三个细节值得抄:

  1. 孤儿元数据也算"丢了"。被摘要截断掉的消息压根没进过 simple_chat_history,所以没有任何 ChatMessageSimple 带着它的 file_id;代码用"全集减去存活集"把它们也补进 dropped 列表(llm_loop.py:542-548)。
  2. 这张清单自己也占 token。加进去后要回头再扣预算,扣不够就继续往外踢历史消息,而被踢掉的文件消息还要再加进清单里、重建这条消息llm_loop.py:570-585)。这是个收敛的小循环,不是一次性计算。
  3. 强调传 UUID 不是文件名。清单文本里专门写了 "You MUST pass the file_id UUID (not the filename)"(llm_loop.py:662-666)——这是对着模型的常见错误打的补丁。

4.5 reminder:永远最后一条

select_reminder_textllm_loop.py:715)每个 cycle 重选一次,是一个优先级链:

优先级条件内容
1刚跑过图片生成IMAGE_GEN_REMINDER:简短描述图片、别放链接
2刚跑过 web_search open_url 工具真的可用 还有 cycleOPEN_URL_REMINDER:鼓励继续打开网页
3其余build_reminder_message 拼装:persona 提醒 + 最后一轮提醒 + 引用提醒 + 文件提醒

第 2 条的 has_open_url_tool 判断是踩过坑的产物——源码注释直说:不 gate 的话模型会被要求去调一个它没有的工具,然后向用户吐出莫名其妙的 "open_url is not available"(llm_loop.py:726-729)。

第 3 条的拼装是纯拼接(backend/onyx/chat/prompt_utils.py:127build_reminder_message):四段各自判断要不要加,最后 strip,全空就返回 None(那这轮就没有 reminder 消息)。

只要本轮任何时候调过检索类工具,引用提醒就一直挂在末尾到整轮结束——这由 should_cite_documents 这个"一旦置真就不再翻回去"的标志实现(llm_loop.py:1384-1388)。

reminder 用的是 Onyx 私有的 MessageType.USER_REMINDER,翻译时才被包进 <system-reminder> 标签变成普通用户消息(下一节)。系统提示里有专门一段告诉模型这个标签是什么意思(backend/onyx/prompts/constants.py:8-15REMINDER_TAG_NO_HEADER)。

4.6 收尾:清理孤儿 tool response

装配的最后一步是 _drop_orphaned_tool_call_responsesllm_loop.py:619)。截断可能把发起工具调用的那条 ASSISTANT 消息丢了,却留下了后面的 TOOL_CALL_RESPONSE——某些供应商(注释点名 Ollama)会直接报 "unexpected tool call id"。这个函数一遍扫过去,维护一个已见 tool_call_id 集合,对不上的 tool response 直接扔掉。


5. 翻译与截断:变成 provider 真能吃的东西

translate_history_to_llm_formatbackend/onyx/chat/llm_step.py:854)是最后一道工序。它做五件事。

5.1 消息类型逐个映射

ChatMessageSimple 类型翻成备注
SYSTEMSystemMessage直通
USER(无图)UserMessage(content=str)直通
USER(有图)UserMessage(content=[TextPart, ...])每张图前额外插一段 [attached image — file_id: ...] 文本(llm_step.py:967-972
USER_REMINDERUserMessage内容包进 <system-reminder>…</system-reminder>llm_step.py:1000-1008
ASSISTANT交给 formatter见下
TOOL_CALL_RESPONSE交给 formatter见下

图片编码失败不会炸整个请求,只记一条 warning 然后跳过这张图(llm_step.py:981-986)。

5.2 按 provider 分叉:Ollama 特例

_get_history_message_formatterllm_step.py:794)是个只有两个分支的策略选择器:Ollama 一套,其余一套。

  • 默认(_DefaultHistoryMessageFormatterllm_step.py:748)→ 走 _build_structured_assistant_messagellm_step.py:703)和 _build_structured_tool_response_messagellm_step.py:725),产出标准的 tool_calls 数组和 role="tool" 消息。
  • Ollama(_OllamaHistoryMessageFormatterllm_step.py:756)→ 把结构压成纯文本:工具调用变成 assistant 正文里的 [Tool Call] name=… id=… args={…} 行,工具返回变成一条 role="user"[Tool Result] id=…llm_step.py:761-787)。

这是"降级到最小公分母"的典型:结构化 tool 协议吃不下,就退回文本协议,语义靠约定的前缀保住。

另有一处按模型名分叉:某些 OpenAI 推理模型需要在系统提示前加 "Formatting re-enabled. " 才会正常输出 markdown(llm_step.py:1033-1045,判定看模型名加 deployment 名,常量 CODE_BLOCK_MARKDOWNbackend/onyx/prompts/chat_prompts.py:85)。

5.3 图片数上限:保新不保多

resolve_image_capllm_step.py:813)目前只对 Azure 生效,且要显式打开 ENABLE_AZURE_IMAGE_CAP,上限 50。真正有意思的是超限时留哪些_select_recent_image_indicesllm_step.py:822):

  • 跨消息按新→旧:新的对话轮次优先。
  • 同一条消息内按附加顺序正序:因为用户手动附的图排在 image_files 前面,项目上下文图是追加在后面的——名额紧张时优先保住用户自己贴的图(源码注释明说,llm_step.py:827-832)。
  • 只统计 USER 消息上 file_type == IMAGE 的条目,免得名额浪费在根本不会被发出去的文件上。

被丢掉的图不是静默消失:末尾会追加一条 <system-reminder> 说"有 N 张早先的图片被省略了"(llm_step.py:1022-1031,模板 IMAGE_DROP_REMINDERchat_prompts.py:79)。

5.4 prompt cache 的切点

翻译时顺带算"可缓存前缀"到哪一条为止:从头扫,只要 should_cache 一直为真就往后推,一旦断了就定死(llm_step.py:919-923)。最后按这个下标把消息切成 prefix / suffix 交给 process_with_prompt_cachellm_step.py:1048-1056)。注意源码注释诚实地承认:这个切点是按翻译前的类型算的,某些 provider 会把工具历史压成纯文本,切点的语义会变弱,但顺序仍是对的(llm_step.py:865-867)。

5.5 为什么要留 5% 白边

预算不是直接用模型的 max_input_tokens,而是先砍一刀:

# backend/onyx/chat/llm_loop.py:704-706
available_tokens = int(
llm.config.max_input_tokens * (1 - GEN_AI_INPUT_TOKEN_SAFETY_MARGIN)
)

动机写在常量定义处(backend/onyx/configs/model_configs.py:71-74):Onyx 用的是通用 tiktoken 估算,可能少数于供应商真实的分词结果,一少数就溢出窗口、整个请求 400。默认留 5%,并且启动时就校验它必须落在 [0, 1) 区间——大于等于 1 会把预算归零,负数会把预算吹到超过真实上限(model_configs.py:76-82)。


6. 提示词本体:按当轮能力动态长出来的段落

系统提示不是一段死文本,是模板 + 按需插入的小节

默认模板在 backend/onyx/prompts/chat_prompts.py:13DEFAULT_SYSTEM_PROMPT),里面埋了三个占位符:

占位符定义处被替换成
{{CURRENT_DATETIME}}chat_prompts.py:5当前日期(handle_onyx_date_awareness
{{CITATION_GUIDANCE}}chat_prompts.py:6引用规范,只在该引用时插入
{{REMINDER_TAG_DESCRIPTION}}chat_prompts.py:7解释 <system-reminder> 标签是什么

这套用字符串替换而不是模板引擎,是为了让用户在管理后台自己编辑默认提示时也能用这些占位符chat_prompts.py:10-12 的注释直说)。默认提示可被库里的 persona 覆盖(backend/onyx/chat/prompt_utils.py:50get_default_base_system_prompt)。

组装函数是 build_system_promptbackend/onyx/chat/prompt_utils.py:226),顺序固定:

基础提示(替换日期 / 引用 / 标签占位符)
└─ + "# User Information" 段(姓名、团队、偏好、记忆,按此顺序)
└─ + 引用规范(若模板里没有占位符则补在这里)
└─ + "# Tools" 段 ← 只放【本轮真正可用】的工具指引

Tools 段是动态的:代码逐个 isinstance 检查工具列表,有搜索工具才加搜索指引、有 python 工具才加 python 指引,等等(prompt_utils.py:243-287)。而且顺序是硬编码的,源码注释解释了为什么不把指引下放到各个 Tool 类里自带:"because the ordering may matter"(prompt_utils.py:258)。

这正是开头那条 30%→90% 实验的落地:指令要和它所属的小节待在一起。"发现信息不够就继续调工具"这句话被写进 TOOL_DESCRIPTION_SEARCH_GUIDANCEbackend/onyx/prompts/tool_prompts.py:7-13)而不是提示开头,就是这个原因。

还有一个和预算相关的巧劲:估算预留 token 时,会用 include_all_guidance=True 造一个把所有工具指引都塞满的假提示去数(prompt_utils.py:84-91calculate_reserved_tokens)。这样预留的是最坏情况,之后真实提示只会更短。


7. 文档表示与引用编号:为什么字段名叫 "document"

7.1 给模型看的文档长这样

不管是项目文件还是检索结果,喂给模型的都是一段带前缀说明的 JSON:

Here are some documents provided for context, they may not all be relevant:
{
"documents": [
{"document": 1, "title": "Q3 规划", "contents": "……"},
{"document": 2, "title": "会议纪要", "contents": "……"}
]
}

生成它的是 _create_context_files_messagellm_loop.py:680),键的顺序就是 documenttitlecontentsllm_loop.py:698-701)。

7.2 三个刻意的设计

(1)字段名叫 document 而不是 citation_id 理由是防止模型在推理里冒出 "I should reference citation_id: 5 for…" 这类怪话——名字本身会影响输出腔调(依据:chat/README.md "Reasons / Experiments")。

(2)编号是一个纯数字,而且跨来源连续。 项目文件先占号(_build_context_file_citation_mappingllm_loop.py:315,默认从 1 开始),检索工具再从 citation_processor.get_next_citation_number() 接着往下发(llm_loop.py:1123)。模型只需要写 [1][2],把号码翻译回真实链接是后端的事——这一层的转换见 04-retrieval-and-citations.md

(3)短字段在前、长正文在后。 检索工具的渲染函数把 document / title 放最前,然后是 updated_at / authors / source_type / url 这些短字段,正文 content 排在后面(backend/onyx/tools/tool_implementations/utils.py:82-112convert_inference_sections_to_llm_string)。理由是模型的局部注意力更强:把编号和标题放在离彼此很近的地方,模型引用时不容易串号。

代码与文档不一致,按代码为准: chat/README.md 的示例把 metadata 排在 contents 之前,但检索工具实际把 metadata 写在 content 之后tools/tool_implementations/utils.py:112-114)。只有 open_url 工具是严格"metadata 在前、content 在最后"(backend/onyx/tools/tool_implementations/open_url/open_url_tool.py:383-402)。另外顶层键名也有分歧:项目文件消息用 documents,两个工具的返回都用 resultsutils.py:118open_url_tool.py:408)。

open_url 那边还有个额外的精打细算:它把除正文外的所有字段拼好、量出这些"元数据开销"占多少字符,再拿剩余预算去截正文;剩余不够 MIN_CONTENT_CHARS 就干脆不收这篇文档(open_url_tool.py:386-396)。


8. 长会话压缩:把老对话换成一段摘要

8.1 什么时候压

判断在一整轮结束、结果落库之后做(backend/onyx/chat/process_message.py:2080-2090),不是在循环里做。逻辑很短(backend/onyx/chat/compression.py:88get_compression_params):

available = max_input_tokens − reserved_tokens
trigger_threshold = available × COMPRESSION_TRIGGER_RATIO # 默认 0.75
若 history_tokens ≤ threshold → 不压
否则 tokens_for_recent = history_tokens × RECENT_MESSAGES_RATIO # 0.2

两个比例分别在 backend/onyx/configs/chat_configs.py:107(可用环境变量调)和 backend/onyx/chat/compression.py:44(写死)。

注意 tokens_for_recent 是按当前历史的 20% 算,不是按可用窗口算。源码注释给了理由:这样保证压缩触发时一定有东西可压compression.py:112-114)——若按窗口比例算,可能出现"要保留的量比历史还多"的空转。

8.2 摘要存成一条独立消息

这是压缩模块最关键的设计决定。摘要不是写回被压缩的那条消息里,而是新建一条 ChatMessage 挂在分支末端,带两个指针(compression.py:477-487):

[消息1] ─ [消息2] ─ [消息3] ─ [消息4] ─ [消息5]
▲ │ ▲
└────────────────────┼──────────────────┤
last_summarized_message_id │ parent_message_id
(摘要覆盖到消息3为止) [摘要消息]

为什么不能写回原消息? 因为消息 3 会被多个分支共享。如果把"消息 1-3 的摘要"塞进消息 3,那摘要里就含有消息 4、5 的信息,而这些信息在别的分支里根本不存在(依据:backend/onyx/chat/COMPRESSION.md "Branch-Aware via Tree Structure")。做成挂在分支尖端的新节点,它就只对这一条分支生效。

配套的查找函数 find_summary_for_branchcompression.py:122)用一个朴素但有效的判据:把本会话所有摘要按时间倒序拉出来,第一个 parent_message_id 落在当前历史 id 集合里的就是该分支的摘要(compression.py:157-159)。注释也解释了为什么在 Python 里过滤而不是写 IN 子句——摘要通常没几条,避免为长历史构造巨大的 IN 列表。

8.3 摘要怎么生成:割断标记法

generate_summarycompression.py:303)拼出来的输入长这样(compression.py:349-363):

SystemMessage: 摘要指令(若有旧摘要,追加 PROGRESSIVE_SUMMARY_SYSTEM_PROMPT_BLOCK)
older_messages…… ← 要被总结的部分
UserMessage: SUMMARIZATION_CUTOFF_MARKER ← 割断标记
recent_messages…… ← 只作为"判断什么重要"的上下文,不进摘要
UserMessage: 最后一句 reminder

割断标记之后的近期消息是给模型判断取舍用的,不参与被总结的范围。这一手又一次体现了本章主题:同样的材料,位置不同、职责不同。

还有三个值得注意的点:

  1. 渐进式摘要:有旧摘要时,只取旧摘要 cutoff 之后的消息(compression.py:201-206),旧摘要文本本身塞进系统提示让新摘要吸收它,避免长对话反复丢信息。
  2. 割断点必须落在用户消息之前:保留段开头如果是助手消息或工具消息,就一路弹出直到开头是 USER(compression.py:231-232)。这是为了让历史在"话题边界"处断开——模型在消息边界处切换话题的能力最好。
  3. 摘要用的翻译是另一套_build_llm_messages_for_summarizationcompression.py:260)把工具调用压成一行 [Used tools: a, b]、把 TOOL_CALL_RESPONSE 整个跳过、不处理图片。注释明确说这是故意translate_history_to_llm_format 不同的(compression.py:266-270)。

数据库连接的握法也讲究:读(找旧摘要 + 工具名映射)用一个短会话,LLM 调用期间不持有任何连接,写摘要再开一个新会话(compression.py:438-487)。代价是 chat_history 里的对象可能已 detached,所以调用方必须提前 eager-load tool_calls 关系,否则会 DetachedInstanceError——这条约定写在 docstring 里(compression.py:402-408)。

压缩整体包在 try/except 里,失败只返回 CompressionResult(error=...),不影响主流程(compression.py:501-509)。

8.4 摘要生效时,历史怎么被砍

下一轮加载时,process_message 找到分支摘要,把 id <= cutoff 的消息全部过滤掉(process_message.py:846-847)。被砍掉的消息上挂的文件也不会人间蒸发——它们的元数据被收进 summarized_file_metadata,最终并进 all_injected_file_metadata,走 §4.4 那条"遗忘文件清单"的路(process_message.py:829-845process_message.py:1024-1026)。这里的 approx_char_count 填 0,注释说明这是"不加载文件就不知道真实大小"时的未知信号。


9. 工具返回的取舍:留参数,扔结果

9.1 两种命运

同一个工具返回,在本轮内下一轮待遇完全不同:

阶段tool response 的内容token 计法代码位置
本轮循环内tool_response.llm_facing_response 原文实算llm_loop.py:1359-1369
落库后再次加载一句占位串硬编码 20chat_utils.py:797-809

占位串本体只有一行:

# backend/onyx/prompts/chat_prompts.py:95
TOOL_CALL_RESPONSE_CROSS_MESSAGE = """
This tool call completed but the results are no longer accessible.
""".strip()

替换发生在 _build_tool_call_response_history_messagebackend/onyx/chat/chat_utils.py:689)。

9.2 为什么这么划算

扔掉的是最胖的、留下的是最有信息量的。 一次内部检索的返回可能几千 token,而"用什么 query 搜的"通常只有十几个 token。工具调用的参数完整保留ToolCallSimple.tool_argumentschat_utils.py:762-769),所以模型下一轮还知道"我搜过什么、别重复搜"——这也正是 INTERNAL_SEARCH_GUIDANCE 里那句"不要重复已经在历史里跑过的相似查询"能生效的前提(backend/onyx/prompts/tool_prompts.py:22-30)。

真正有用的结论,此时已经被写进上一轮的助手回答里了。

9.3 两个例外

  • 图片生成工具不走占位串。它的返回被换成一个 [{"file_id": ..., "revised_prompt": ...}] 的 JSON,好让下次会话还能"重放"图片(chat_utils.py:606-627),token 数也是实算而非 20。
  • 内部检索的查询扩展:真正跑的 query 和模型给的参数可能不同,历史里存的是扩展后的全套 query,因为那对后续判断更有信息量(依据:chat/README.md "Tool Calls" 的 Note)。

10. 巧妙之处(可以直接抄的)

#妙在哪位置
1指令要和它的小节住在一起——同一句话挪出 Tools 段就从 90% 遵从掉到 30%chat/prompt_utils.py:272-316
2自定义 agent 提示做成用户消息且随对话移动,而不是塞系统提示llm_loop.py:596-597llm_loop.py:961-969
3两种文件两种位置策略:项目文件漂移、上传文件定点llm_loop.py:352chat_utils.py:689-702
4文件消息打 file_id 标签,截断后靠"存活集 vs 全集"反查被遗忘的文件models.py:165llm_loop.py:542-548
5降级不是删除:塞不下的文件变成 read_file 清单,而清单自己也要重新参与预算收敛llm_loop.py:553-585
6reminder 永远最末,且 open_url 提醒 gate 在工具真的存在上llm_loop.py:715-740
7截断用 break 不用 continue,保住连续前缀,prompt cache 才有意义llm_loop.py:517-526
8给通用分词器留 5% 白边,并在启动时校验这个比例的合法区间configs/model_configs.py:71-82
9图片超限时优先保用户手贴的图(消息间按新,消息内按序)llm_step.py:822-851
10摘要作为独立节点挂分支尖端,天然做到分支隔离compression.py:477-485
11割断标记法:近期消息只当"判断重要性的上下文",不进摘要compression.py:349-363
12扔结果留参数:省下最胖的 token,保住"我搜过什么"这条最有用的信息chat_utils.py:601llm_loop.py:1359-1369
13孤儿 tool response 清理,避免某些 provider 直接 400llm_loop.py:619-650
14provider 分叉降级到纯文本:Ollama 吃不下结构化 tool 协议就退回文本约定llm_step.py:756-787

11. 边界与局限(诚实的部分)

已知的粗糙处,源码自己承认的:

  • 预留 token 多了,压缩就会频繁触发——慢、贵、体验差。COMPRESSION.md 的 "Token Budget" 一节把这条列为待改进项。
  • 工具返回是硬删不是摘要chat/README.md 提到未来可能改成 LLM 摘要成 1-2 句,但同时自我质疑"有用的东西本来就该在助手回答里了",价值存疑。
  • 文件类型处理不全extract_context_files 里挂着 # TODO(yuhong): I believe this is not handling all file types correctly.process_message.py:409)。
  • 项目图片不能被引用。因为图片挂在最后一条用户消息上、没有对应文本,所以进不了引用体系;源码 TODO 提出的思路是把图片拆成独立的带引用信息的消息(llm_loop.py:832-833)。
  • 文件全部加载process_message.py:933 有 TODO:等摘要功能完善后就不必一开始加载全部文件了。

硬失败而不是静默降级的地方: 最后一条用户消息 + 其后的工具消息如果装不下预算,直接抛 ValueErrorllm_loop.py:502-506);预算扣成负数也直接抛(llm_loop.py:439-440)。这是刻意的——这种情况下静默截断只会给出无声的错答案。

文档与代码的已知漂移: §7.2 记的 metadata 字段位置、顶层键名(documents vs results)在 README 与代码之间不一致。以代码为准。


12. 横向对比(放在货架上看)

同一个 shelf 里,Onyx 的取舍有几处很有辨识度:

  • "位置策略"被显式建模。很多 agent 框架只有"历史 + 系统提示"两格,Onyx 把"定点 / 漂移 / 永远最末"做成了三种一等公民的位置语义。
  • 压缩是分支感知的。带对话树(编辑 / 重生成)的产品做压缩,几乎必然要面对"摘要归属哪个分支",Onyx 用一条带双指针的新消息解决,代价只是多一次树内查找。
  • 降级链完整:装得下 → 进上下文;装不下 → 转检索;检索也没有(向量库关闭)→ 转 read_file 清单;被截断丢了 → 也转 read_file 清单。每一层都有明确出口,没有"就这么没了"。

本章的兄弟章节:01-chat-turn-loop.md 讲这套装配跑在什么循环里;03-tools-and-subagents.md 讲工具定义与并行执行;04-retrieval-and-citations.md 讲编号怎么变回带链接的引用;06-runtime-and-deployment.md 讲这些配置项在部署层怎么落。


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

主题文件路径符号名
设计笔记与实验结论backend/onyx/chat/README.md—("Reasons / Experiments" 节)
消息的规范表示backend/onyx/chat/models.pyChatMessageSimpleToolCallSimpleChatLoadedFile
文件的两种形态backend/onyx/chat/models.pyFileToolMetadataContextFileMetadataExtractedContextFiles
文件溯源全集backend/onyx/chat/models.pyChatHistoryResult
装配顺序(核心)backend/onyx/chat/llm_loop.pyconstruct_message_history
项目文件消息backend/onyx/chat/llm_loop.py_build_project_message_create_context_files_message
遗忘文件降级清单backend/onyx/chat/llm_loop.py_create_file_tool_metadata_message
reminder 选择backend/onyx/chat/llm_loop.pyselect_reminder_text
孤儿 tool response 清理backend/onyx/chat/llm_loop.py_drop_orphaned_tool_call_responses
引用编号映射(文件侧)backend/onyx/chat/llm_loop.py_build_context_file_citation_mapping
翻译成 LLM 格式backend/onyx/chat/llm_step.pytranslate_history_to_llm_format
provider 分叉backend/onyx/chat/llm_step.py_get_history_message_formatter_OllamaHistoryMessageFormatter
结构化消息构造backend/onyx/chat/llm_step.py_build_structured_assistant_message_build_structured_tool_response_message
图片上限backend/onyx/chat/llm_step.pyresolve_image_cap_select_recent_image_indices
DB 历史 → 规范表示backend/onyx/chat/chat_utils.pyconvert_chat_historybuild_file_context
工具返回占位串backend/onyx/chat/chat_utils.py_build_tool_call_response_history_message
系统提示组装backend/onyx/chat/prompt_utils.pybuild_system_promptbuild_reminder_messagecalculate_reserved_tokens
提示词模板与提醒文本backend/onyx/prompts/chat_prompts.pyDEFAULT_SYSTEM_PROMPTCITATION_REMINDERTOOL_CALL_RESPONSE_CROSS_MESSAGE
按工具插入的指引backend/onyx/prompts/tool_prompts.pyTOOL_SECTION_HEADERINTERNAL_SEARCH_GUIDANCE
<system-reminder> 标签说明backend/onyx/prompts/constants.pyREMINDER_TAG_NO_HEADERSYSTEM_REMINDER_TAG_OPEN
压缩backend/onyx/chat/compression.pyget_compression_paramsfind_summary_for_branchget_messages_to_summarizegenerate_summarycompress_chat_history
压缩设计笔记backend/onyx/chat/COMPRESSION.md
上下文文件加载与摘要截断backend/onyx/chat/process_message.pyextract_context_files
消息树与工具调用树backend/onyx/db/models.pyChatMessageToolCall
存储层设计说明backend/onyx/db/README.md
文档 JSON 渲染backend/onyx/tools/tool_implementations/utils.pyconvert_inference_sections_to_llm_string
LLM 接口消息类型backend/onyx/llm/models.pyLanguageModelInputChatCompletionMessage
token 安全余量backend/onyx/configs/model_configs.pyGEN_AI_INPUT_TOKEN_SAFETY_MARGIN
压缩触发比例backend/onyx/configs/chat_configs.pyCOMPRESSION_TRIGGER_RATIO