【架构设计】Agent 编排,和服务端算子化是一回事吗?

38 阅读11分钟

1. 服务端为什么需要算子化

在业务开发过程中,架构研发会说:“我不想了解业务,这不是我该关心的事。何况有一些业务代码写的还很乱”。业务研发会说:“这次我要新接入一个下游服务,但是现在服务性能不行接不了,架构优化不动不给力”。两者相互耦合,相互牵扯,在此过程中,算子化的设计思想就被提出了。

算子化主要做了以下两件事:

  • 图描述计算:通过 DAG 图来描述算子Op的连接结构,Op算子的输入输出来明确功能和数据的影响范围
  • 性能优化和业务逻辑正交:架构优化收敛到Op框架中,业务逻辑收敛到算子Op中,两者完全正交,互不影响

下面从分层架构看,一套可落地的算子化服务端通常包含哪些部分。


2. 算子化的架构分层

一个可维护的算子化服务端通常分为四层:

层级职责
API LayerHTTP/gRPC 入口、限流熔断
Graph Layer算子图 DAG 定义、建图
Scheduler Layer调度器框架
Operator Layer具体算子:召回、过滤、打分、重排……(业务逻辑)

架构设计上的关键约束:

  • Graph 无状态——有状态的操作需要隔离到API Layer,例如限流、熔断;算子Op不接收有状态操作
  • Scheduler 可观测——只负责调度、资源与观测,该层负责埋点性能指标,对业务代码无入侵

这样,架构研发关注API LayerScheduler Layer,业务研发关注Graph LayerOperator Layer。此外,Graph Layer在做数据流建模时,需要架构把关,避免走偏


3. 算子图建模

对于算子图,我们通常希望它:

  1. 图依赖显式声明:明确描述Op算子有哪些,Op算子之间的哪些数据在传递
  2. 算子级热插拔:可熔断(故障隔离)、可短路(热摘除)

要满足这些能力,设计时必须隔离控制流与数据流

数据流回答「算什么、传什么」——算子之间通过有类型的输入/输出连接,依赖关系构成 DAG。召回列表、特征、打分结果等业务载荷只在这条流里传递;不应依赖全局变量、隐藏单例,或 RequestContext 里的「魔法字段」在算子间偷传数据。

控制流回答「要不要算、怎么算」——是否执行、是否并行、是否超时、是否熔断、是否跳过下游,属于 Graph + Scheduler 的策略。算子可以返回 Status::kSkipStatus::kDegraded 等信号,但不应在内部硬编码「下游是谁、失败时替谁兜底」——那会把拓扑焊死在代码里。

flowchart LR
  subgraph dataFlow["数据流(Graph 声明)"]
    A[Op A] -->|data_a| B[Op B] -->|data_b| C[Op C]
  end
  subgraph ctrlFlow["控制流(Scheduler 执行)"]
    P[&#34;NodePolicy<br/>enabled · circuit · timeout&#34;]
  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. 数据建模

数据建模的过程中,其实有两种方式:

  1. 所有节点共用Context,每个Op算子自己按需获取。这种方式看似简单,但是时间久了会变得难以维护,Context中的某个变量什么时候被修改会难以追溯。根本原因是业务研发的约束太少,导致最终野蛮生长
  2. 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[&#34;Op A<br/>Schema A&#34;] -->|data_a| M[&#34;Mapping<br/>A → B&#34;]
  M -->|data_b| B[&#34;Op B<br/>Schema B&#34;]

对齐通常用**映射表(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/errorScheduler 包装计时
输入/输出规模读对齐后的 schema
调用链每个节点自动打 Span
实验分桶API 入口写进 Context

前两行好理解,后两个容易和代码对不上,单独说一下。

代码里的 ScopedSpan span(ctx.trace, node.name) 会在进入/退出 RunOperator 时自动开闭一条 Span——就是 trace 里的一格计时,名字通常就是 RecallRank 这类节点名。一条请求跑完,展开 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 的表述很贴切:编排应成为独立于业务逻辑的一层,原子能力(ChatModelRetrieverToolsNode 等)是「第一公民」,开发者主要做组合与串联,而不是在业务代码里散落 if-else 调组件。

flowchart TB
  subgraph server[&#34;服务端算子化&#34;]
    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[&#34;Agent 编排&#34;]
    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 对齐
控制流熔断、短路、超时、SkipRetry、分支、循环、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。

改图不改码,摘节点不动算子。