数据截至 (上游 commit dad6f5196773)
上下文工程:每次调模型前,消息和工具是怎么被拼出来的
30 秒导读: LobeHub 里,数据库存的消息是给人看的一棵树(assistantGroup、agentCouncil、tasks、verify 卡片……),而模型只认一条扁平的
{role, content}数组。packages/context-engine就是这两者之间那台机器:一条顺序执行的处理器流水线,把树压平、把该注入的知识/记忆/技能/日期塞进正确的插槽、把工具清单编码成合法函数名,最后吐出一份可以直接交给call_llm的载荷。
本章只讲 call_llm 之前那一段。指令是谁发出来的、引擎怎么跑,见 运行时内核;拼好的载荷怎么送进几十家供应商,见 模型运行时。
1. 这是什么(零基础也能懂)
一句话定义: context-engine 是 LobeHub 的提示词装配线——输入是一堆 UI 用的消息对象和一堆配置,输出是模型 API 能直接消费的 messages 数组和 tools 数组。
它要解决的问题
想象你在 LobeHub 里发了一句"帮我看看这个 PDF"。等模型真正收到时,这句话周围其实还挂着一大堆东西:
- 这个 agent 的人设(system role);
- 今天的日期、模型的知识截止日期;
- 你的长期记忆、这个 agent 的知识库文件;
- 当前可用的技能清单、工具清单;
- 上一轮对话的压缩摘要;
- 你刚才勾选的那几个工具。
这些东西各有各的正确位置:人设必须在 system 消息里,知识库放在第一条 user 消息之前(前缀稳定 → 命中缓存),todo 列表得贴在最后一条 user 消息末尾(要反映最新状态)。同时,数据库里那些"一个 assistant 带三个工具结果"的合并卡片,必须先拆回 assistant → tool → tool → tool 的扁平序列,否则模型直接报 400。
把这套规则写成一坨 if-else,就是所有聊天产品最后都会烂掉的那个文件。 context-engine 的答案是:拆成几十个独立的小处理器,排成一条流水线。
用起来什么样
最小调用长这样(真实签名见 packages/context-engine/src/engine/messages/MessagesEngine.ts:101 的 MessagesEngine):
// 示意,非源码
const engine = new MessagesEngine({
messages, // 数据库里的 UIChatMessage[]
model: 'gpt-4o',
provider: 'openai',
systemRole: 'You are a helpful assistant',
capabilities: { isCanUseFC: () => true, isCanUseVision: () => true },
});
const { messages: payload, metadata, stats } = await engine.process();
// payload 就是 OpenAI 格式的 messages 数组
一句话直觉
把它当成印刷厂的装版流程。 稿件(消息)先裁掉多余的页(截断),再依次盖上页眉(system prompt)、插页(知识/记忆)、页脚(todo/工具选择),最后统一校对字号(格式清洗)才上机。每道工序都不知道别的工序存在,只认自己那一步。
2. 顶层全景(它大概怎么转)
一张图
怎么读:从上往下就是执行顺序;左侧是"数据长什么样",右侧是"这一层由谁负责"。
输入: UIChatMessage[] (数据库/前端 store 里那棵给人看的对话树)
│
▼
┌──────────────────────────────────────────────┐
│ ContextEngine.process() │ pipeline.ts
│ ── 顺序跑 processors,带耗时统计与提前终止 ── │
└──────────────────────────────────────────────┘
│ 流经的是同一个 PipelineContext 对象
│
├─ ① 裁剪 砍掉过老的历史(按"组"砍,不腰斩)
├─ ② 装 system 人设 / 日期 / 技能清单 / 工具说明 / 历史摘要
├─ ③ 首 user 前 知识库 / 用户记忆 / 计划 / 群组身份
├─ ④ 末 user 后 todo / 选中工具 / 页面内容 / 话题引用
├─ ⑤ 形变 把树压平:群聊卡片 / 任务卡片 / 角色改写
├─ ⑥ 内容 图片转 base64 / tool_calls 转 OpenAI 格式
└─ ⑦ 收尾 tool 消息配对重排 → 字段清洗
│
▼
输出: OpenAIChatMessage[] + metadata(每步做了什么) + stats(每步耗时)
旁路(不在 pipeline 里,但同样喂给 call_llm):
engine/tools/ → tools[] 工具清单与合法函数名
engine/skills/ → skills 技能激活状态
tokenAccounting/ → 该不该压缩了
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ContextEngine | 顺序跑处理器,统计耗时,包错误 | packages/context-engine/src/pipeline.ts:19 |
ContextProcessor | 处理器唯一契约:name + process() | packages/context-engine/src/types.ts:89 |
PipelineContext | 流经全程的可变状态包 | packages/context-engine/src/types.ts:70 |
base/* | 四种"往哪儿注入"的插槽基类 | packages/context-engine/src/base/ |
processors/* | 清洗、裁剪、模板、形变、任务 | packages/context-engine/src/processors/ |
providers/* | 各类内容注入器(谁塞什么) | packages/context-engine/src/providers/ |
MessagesEngine | 预置好的 7 阶段默认流水线 | packages/context-engine/src/engine/messages/MessagesEngine.ts:101 |
engine/tools/* | 工具清单装配 + 函数名编解码 | packages/context-engine/src/engine/tools/ |
engine/skills/* | 技能集装配与激活合并 | packages/context-engine/src/engine/skills/ |
tokenAccounting/* | 分类估算 token,判定压缩阈值 | packages/context-engine/src/tokenAccounting/ |
主线走一遍(不进代码)
- 调用方(浏览器或服务端)收集好一切上下文,
new MessagesEngine({...})。 MessagesEngine.buildProcessors()按 7 个 Phase 把几十个处理器排成一个数组(MessagesEngine.ts:145)。ContextEngine.process()从头到尾跑一遍, 每步都可能改写context.messages。- 拿到扁平消息数组;工具清单由
ToolsEngine另外算好;两者一起送去调模型。
3. 管道骨架:ContextEngine.process()
这节讲什么: 流水线本身有多简单——简单到值得直接读完。
3.1 处理器契约只有两个字段
// packages/context-engine/src/types.ts:89
export interface ContextProcessor {
name: string;
process: (context: PipelineContext) => Promise<PipelineContext>;
}
没有生命周期钩子、没有依赖声明、没有优先级数字。 顺序 = 数组顺序。这是整个包最重要的设计决定:所有"谁先谁后"的知识,集中写在 MessagesEngine.buildProcessors() 一个地方,而不是散落在几十个类的元数据里。