1. 服务端为什么需要算子化
在业务开发过程中,架构研发会说:“我不想了解业务,这不是我该关心的事。何况有一些业务代码写的还很乱”。业务研发会说:“这次我要新接入一个下游服务,但是现在服务性能不行接不了,架构优化不动不给力”。两者相互耦合,相互牵扯,在此过程中,算子化的设计思想就被提出了。
算子化主要做了以下两件事:
- 图描述计算:通过 DAG 图来描述算子Op的连接结构,Op算子的输入输出来明确功能和数据的影响范围
- 性能优化和业务逻辑正交:架构优化收敛到Op框架中,业务逻辑收敛到算子Op中,两者完全正交,互不影响
下面从分层架构看,一套可落地的算子化服务端通常包含哪些部分。
2. 算子化的架构分层
一个可维护的算子化服务端通常分为四层:
| 层级 | 职责 |
|---|---|
| API Layer | HTTP/gRPC 入口、限流熔断 |
| Graph Layer | 算子图 DAG 定义、建图 |
| Scheduler Layer | 调度器框架 |
| Operator Layer | 具体算子:召回、过滤、打分、重排……(业务逻辑) |
架构设计上的关键约束:
- Graph 无状态——有状态的操作需要隔离到API Layer,例如限流、熔断;算子Op不接收有状态操作
- Scheduler 可观测——只负责调度、资源与观测,该层负责埋点性能指标,对业务代码无入侵
这样,架构研发关注API Layer和Scheduler Layer,业务研发关注Graph Layer和Operator Layer。此外,Graph Layer在做数据流建模时,需要架构把关,避免走偏
3. 算子图建模
对于算子图,我们通常希望它:
- 图依赖显式声明:明确描述Op算子有哪些,Op算子之间的哪些数据在传递
- 算子级热插拔:可熔断(故障隔离)、可短路(热摘除)
要满足这些能力,设计时必须隔离控制流与数据流。
数据流回答「算什么、传什么」——算子之间通过有类型的输入/输出连接,依赖关系构成 DAG。召回列表、特征、打分结果等业务载荷只在这条流里传递;不应依赖全局变量、隐藏单例,或 RequestContext 里的「魔法字段」在算子间偷传数据。
控制流回答「要不要算、怎么算」——是否执行、是否并行、是否超时、是否熔断、是否跳过下游,属于 Graph + Scheduler 的策略。算子可以返回 Status::kSkip、Status::kDegraded 等信号,但不应在内部硬编码「下游是谁、失败时替谁兜底」——那会把拓扑焊死在代码里。
flowchart LR
subgraph dataFlow["数据流(Graph 声明)"]
A[Op A] -->|data_a| B[Op B] -->|data_b| C[Op C]
end
subgraph ctrlFlow["控制流(Scheduler 执行)"]
P["NodePolicy<br/>enabled · circuit · timeout"]
end
P -.-> A
P -.-> B
P -.-> C
| 维度 | 数据流 | 控制流 |
|---|---|---|
| 关注点 | 输入/输出契约、依赖边 | 调度、熔断、短路、超时、降级 |
| 声明位置 | Graph Layer(DAG 定义) | Graph + Scheduler(策略与执行) |
| 典型变更 | 加特征、换模型、改输出 schema | 摘流、限流、扩缩、故障隔离 |
落到设计约束:
- 算子只产出数据,不决定后继——后继是否执行,由调度器根据依赖边与策略判断。
- 短路/熔断不破坏契约——被跳过的算子,下游要么不依赖其输出(真分支),要么引擎提供约定好的空输出/默认输出,避免
nullptr散落各处。 - Context 只承载横切元数据——trace、超时令牌、实验分桶可进
RequestContext;跨算子的业务结果必须走显式 Data Edge。
在此前提下,「热摘除一个重排算子」改图配置即可;「熔断一个频繁超时的召回源」在该边挂策略即可,Rank 算子无需跟着改。
下面用一段精简代码说明二者如何分工:算子只管数据变换,Scheduler 只管是否执行。
// ── 数据流:节点、依赖、显式数据绑定 ──
ADD_NODE(A);
ADD_NODE(B);
ADD_NODE(C);
ADD_NODE(D);
ADD_EDGE(A, B);
ADD_EDGE(A, C);
ADD_EDGE(B, D);
ADD_EDGE(C, D);
ASSIGN_DATA(A, data_a, B, data_b);
ASSIGN_DATA(A, data_a, C, data_c);
ASSIGN_DATA(B, data_b, D, data_d);
ASSIGN_DATA(C, data_c, D, data_d);
// ── 控制流:与数据边正交,Scheduler 解释执行 ──
DISABLE_NODE(B); // 热摘除:不执行 B,注入兜底值
flowchart TB
A[Op A] --> B[Op B]
A --> C[Op C]
B --> D[Op D]
C --> D
4. 数据建模
数据建模的过程中,其实有两种方式:
- 所有节点共用Context,每个Op算子自己按需获取。这种方式看似简单,但是时间久了会变得难以维护,Context中的某个变量什么时候被修改会难以追溯。根本原因是业务研发的约束太少,导致最终野蛮生长
- Op算子明确定义Input,Output。这种方式有效解决了方案1野蛮生长的问题,通过明确的约束条件来限制Op算子的功能范围。
接下来我们就用方案2来进行数据建模
§3 里 ASSIGN_DATA(A, data_a, B, data_b) 只声明了数据从哪来到哪去,并未保证 A 的输出结构与 B 的输入结构一致(其实这个就类似微服务之间的Proto协议,需要制定Adaptor来做适配)
因此需要在边上增加数据对齐层:A 产出 data_a,Scheduler 按映射表转换为 B 能消费的 data_b,再调用 B。
flowchart LR
A["Op A<br/>Schema A"] -->|data_a| M["Mapping<br/>A → B"]
M -->|data_b| B["Op B<br/>Schema B"]
对齐通常用**映射表(Field Mapping Table)**描述,挂在 Graph 的 Data Edge 上,而不是写进算子代码里:
| 映射类型 | 含义 | 示例 |
|---|---|---|
| 直传 | 同名字段拷贝 | ids → ids |
| 重命名(通常不允许、建议改成同名) | 字段改名 | recall_score → rank_feat |
| 变换(通常不允许、建议改成同类型) | 类型/语义转换 | int64 id → string id |
| 默认值 | 上游缺失时填充 | source = "" |
// ── 各算子维护自己的 Schema(算子内可见,算子间不可见)──
struct SchemaA { // A 的输出
Span<ItemId> ids;
Span<float> recall_scores;
Span<uint8_t> sources;
};
struct SchemaB { // B 的输入
Span<ItemId> ids;
Span<float> rank_features;
Span<uint8_t> sources;
};
// ── 映射表:声明 A.data_a → B.data_b 的字段对齐规则 ──
BEGIN_MAPPING(A, data_a, B, data_b)
MAP_DIRECT(ids, ids)
MAP_RENAME(recall_scores, rank_features)
MAP_TRANSFORM(sources, sources, CastSource) // 可选变换函数
MAP_DEFAULT(rank_features, 0.0f) // A 未产出时兜底
END_MAPPING()
// ── 与 §3 建图 DSL 组合 ──
ADD_NODE(A);
ADD_NODE(B);
ADD_EDGE(A, B);
ASSIGN_DATA(A, data_a, B, data_b);
BIND_MAPPING(A, data_a, B, data_b, mapping_a2b); // 边绑定映射表
// ── Scheduler 在边上执行对齐(宏展开示意)──
Status AlignAndRun(RequestContext& ctx) {
const SchemaA& raw = slots_.Get<A>("data_a");
SchemaB& aligned = slots_.Alloc<B>("data_b");
for (const auto& rule : mapping_a2b) {
if (!rule.Apply(raw, &aligned)) {
if (!rule.has_default) return Status::kError;
rule.FillDefault(&aligned);
}
}
return ops_["B"]->Execute(ctx, aligned, &slots_["data_c"]);
}
映射表的核心收益:
- A、B 可独立迭代——A 加字段只改
SchemaA和映射表,B 的Execute签名不变。 - 对齐逻辑可配置——映射表可由 Graph 配置加载,实验时可换一张表而不重新编译算子。
- 与 control flow 正交——
DISABLE_NODE(B)时走FALLBACK;映射表只在「B 确实要执行」时生效。
工程上,映射表建议在建图期校验:字段是否存在、类型是否可转换、必填列是否有 MAP_DEFAULT 兜底;校验失败直接拒绝发布,避免线上才暴露对齐错误。
5. 执行引擎与可观测性
Scheduler 统一调度,并承担横切逻辑——业务算子只实现 Execute(in, out),不碰埋点、超时、熔断;观测面从拓扑自动生成,在 Scheduler 层写 LOG/METRIC,不入侵业务逻辑。
sequenceDiagram
participant R as Scheduler
participant O as Operator
R->>R: Span / Timer
alt ShouldRun
R->>O: Execute
O-->>R: Status
R->>R: Record metrics
else 热摘除 / 熔断
R->>R: InjectFallback
end
Scheduler 调 Operator 的路径可以收敛成一次 RunOperator:
Status RunOperator(int id, RequestContext& ctx) {
auto& node = graph_.Node(id);
ScopedSpan span(ctx.trace, node.name);
if (!policy_.ShouldRun(id)) return InjectFallback(id, ctx);
Status st = ops_[id]->Execute(ctx, LoadInput(id, ctx), AllocOutput(id, ctx));
node_metrics_[id].Record(st); // ok / skip / degraded / error + 耗时 + IO 规模
return st;
}
RunOperator 把计时、埋点和策略判断都收在 Scheduler 里,算子侧只需返回 Status。对应关系如下:
| 观测维度 | 谁来做 | 算子要不要管 |
|---|---|---|
| 耗时、ok/skip/error | Scheduler 包装计时 | 否 |
| 输入/输出规模 | 读对齐后的 schema | 否 |
| 调用链 | 每个节点自动打 Span | 否 |
| 实验分桶 | API 入口写进 Context | 否 |
前两行好理解,后两个容易和代码对不上,单独说一下。
代码里的 ScopedSpan span(ctx.trace, node.name) 会在进入/退出 RunOperator 时自动开闭一条 Span——就是 trace 里的一格计时,名字通常就是 Recall、Rank 这类节点名。一条请求跑完,展开 trace 大概长这样:
Request
├── Recall 120ms
├── Filter 15ms
├── Rank 280ms // 500ms 链路里常见拖尾点
└── ReRank 40ms
哪段最长一眼能看,不用在算子实现里手写 timer。
实验分桶是另一路:请求进 API 时,按 user_id 或哈希划进某个实验桶,结果放进 RequestContext。Scheduler 读 ctx.exp 决定走哪版 Graph、哪套参数——Recall/Rank 里不用写 if (实验 B):
ctx.exp.bucket_id = "recall_v2";
ctx.exp.flags["rank_model"] = "b";
这也符合 §2 的分层:跟用户、跟流量相关的判断留在 API;算子只消费已经定好的上下文。线上排查时,trace 找拖尾节点,metrics 看哪个 op 的 degraded 在涨,再回 §3 对熔断/短路配置,一般就够用了。
6. Agent 编排:算子化思想的延伸
服务端算子化谈的是 Op + DAG + Scheduler;Agent 应用谈的是 Component + Graph/Chain + 编排框架——问题域不同,分层方式高度同构。CloudWeGo Eino 的表述很贴切:编排应成为独立于业务逻辑的一层,原子能力(ChatModel、Retriever、ToolsNode 等)是「第一公民」,开发者主要做组合与串联,而不是在业务代码里散落 if-else 调组件。
flowchart TB
subgraph server["服务端算子化"]
S_API[API Layer]
S_Graph[Graph Layer]
S_RT[Scheduler Layer]
S_Op[Operator Layer]
S_API --> S_Graph --> S_RT --> S_Op
end
subgraph agent["Agent 编排"]
A_Entry[入口 / Session]
A_Graph[Graph / Chain]
A_RT[编排 Scheduler]
A_Comp[Component 层]
A_Entry --> A_Graph --> A_RT --> A_Comp
end
6.1 对照:同一套设计原则
| 原则 | 服务端算子化 | Agent 编排 |
|---|---|---|
| 职责分离 | 算子写算法,Scheduler 写调度/观测 | Component 写能力,框架写编排/治理 |
| 数据流 | 显式 Input/Output + 边上映射 | 节点间 Message / State schema 对齐 |
| 控制流 | 熔断、短路、超时、Skip | Retry、分支、循环、Interrupt、Checkpoint |
| 热插拔 | 改 Graph 配置摘节点 | 改 Graph 换 Tool / 换 Model / 插 Human 节点 |
| 可观测 | 按 node_id 自动埋点 | Callback / Trace 按节点采集 |
类型对齐在 Agent 里同样关键:上游 ChatModel 的输出 schema 与下游 ToolsNode 的入参不一致,就要在边上做适配(类似 §4 的 Mapping Table),而不是让 Tool 算子直接读上游的内部结构。
控制流与数据流分离在 Agent 里更明显:数据流是 Message、ToolResult、Memory 片段在节点间传递;控制流是「是否重试」「走哪条分支」「是否触发 Human 确认」——这些应写在 Graph 策略或 Scheduler,而不是塞进每个 Component 的实现里。
6.2 Chain 与 Graph:复杂度如何安放
Eino 的判断可以借用:大多数场景顺序串联即可(Chain),复杂拓扑再用 Graph。
- Chain:Prompt → LLM → Parser → Tool → LLM → Answer,线性、易读,适合固定 SOP。
- Graph:并行召回多路 Memory、条件分支选 Tool、循环直到满足停止条件——适合多 Agent 协作、ReAct、Plan-and-Execute。
和服务端一样:能用 Chain 就不用 Graph,Graph 的能力(分支、并行、环)只在业务需要时引入,避免「为了架构而架构」。
6.3 Agent 特有的差异
算子化服务端与 Agent 编排并非一一对应,落地时还要额外考虑:
- 非确定性:同一输入 LLM 可能给出不同输出,测试与回放需要 Mock / 录播 / 快照。
- 流式:Token 流、Tool 中间结果需要 Stream 边,编排层要处理「半包」与背压(Eino 的流式转换即为此类问题)。
- 长会话状态:Memory、Checkpoint 是有状态的,应隔离在 Session / API 层(类比 §2 里有状态逻辑不进 Graph)。
- 人机协同:Interrupt、Human-in-the-loop 本质是控制流策略,应在 Scheduler 解释,而非写死在 Component 里。
若已有一套算子化 Scheduler(调度、观测、熔断、映射表),向 Agent 场景演进时,优先复用 Graph 定义 + Scheduler 横切 + 边上 schema 对齐 这三件套,再叠加 LLM 特有的 Stream / Checkpoint / Interrupt 即可——不必从零发明第二套编排模型。
总结
四层分工:算子算、Graph 连、Scheduler 调度与观测、API 接有状态逻辑。数据走边,控制走 Scheduler;对不齐就挂映射表。
Agent 侧 Component/Chain 是同一套,另补流式与 Checkpoint。
改图不改码,摘节点不动算子。