# ADR-0020: 具名 flow 执行器 -- 把 "模型 B" 泛化成数据驱动的具名 flow (INF-1 + INF-2) - **Status**: Accepted (PM 拍板 2026-07-12, "别考虑什么 p 几了, 现在已经是消费需求爆炸了, 你尽快弄完善") - **Date**: 2026-07-12 - **Consumers**: flytoCall (电销呼叫中心工作台, 首个厚接入消费者); 后续任意需要 "纯文本进 / 结构化 JSON 出 + 完全租户隔离" 判定/生成能力的行业 platform - **关联**: ADR-0002 (REST 业务 / gRPC 观测 bifurcation), ADR-0008 (quote-probe 协议, 模型 B 先例), ADR-0017 (运行时引擎配置命名角色路由), api-reference.md "Run 端点选型与引擎隔离模型" (c40c4bc) ## 1. 背景 / Context api-reference.md (c40c4bc) 确立了两种引擎实例模型: 模型 A (共享长生命引擎, `/agent/run` `/sessions` `/plans`, 会话级隔离有 / 文件系统级隔离无) 与模型 B (每请求短生命引擎 + 收窄工具集 + 完全租户隔离, 业务垂直端点如 quote-dispatch). 客户业务工作流该走模型 B. 问题不在 "没有能力", 而在 **模型 B 每加一个能力都要手写一个专用大文件** (唯一先例 quotedispatch 手写 3751 行 + 专用路由). `/api/v1/flows` 是 CRUD stub (`server_p2_stubs.go`, 不持久化 / 不接引擎), **无任何 `/run` 执行端点**. `responseguard` 的 schema 校验器只能在 `engine.New()` 编译期装配, 无 REST 入口 (且 quotedispatch 走的是 provider grammar, 从没消费 responseguard -- 它是零消费者的悬空 helper). `RunRequest.SystemPrompt` / `Tools` 收了不读. flytoCall 要把 AI 逻辑从 "薄网关" (prompt 写死在 Go 里裸调 `/agent/run`, `strings.Index` 抠 JSON) 升级为 "厚接入" (AI 逻辑沉进引擎做成具名 flow, 只按名 HTTP 触发). 它要 N 个 call-center flow. 核心诉求 = 把模型 B 泛化成数据驱动的具名 flow. ## 2. 决策 / Decision 把模型 B 泛化成 **数据驱动的具名 flow**: 一个 flow = 一份 `Definition` 数据 `{name, version, input_schema, output_schema, system_prompt_template, tools, model}`; 一个通用执行器 `POST /api/v1/flows/{name}/run` 跑任意 flow. 加一个能力 = 加一个 `Definition` (数据), 不是手写 handler. 四个组件 (P0 全在 platform 层, **零 core 引擎改动**): 1. **`enginefactory.BuildStructuredEngine`** (新, 中性) -- 把 quotedispatch `BuildMainEngine` 的模型 B 装配 (每请求 `engine.New` + `tools.None()` + `LiteralSystemPrompt` + 8 个中性化 flag) 提取成公共件. quotedispatch 私有那份暂不动 (见 §3). 2. **`responseguard.JSONSchemaValidator`** (新) -- 用 santhosh-tekuri/jsonschema v6 做确定性 JSON Schema 校验 (类型 / enum / 范围 / 条件必填 if-then), 嵌 `validator.StructuralMarker`, 挂 `engine.Config.ResponseReflector` 做 in-loop 自纠. 补齐 `JSONVerdictValidator` 只查必填 key 的空档. 3. **`flow` 包** -- `Definition` + `Registry` 接口 + `BuiltinRegistry` (CCM 拥有, 仓内注册) + 输入/输出 schema 校验 + prompt 渲染. 首个 flow `callcenter.wrapup` (话后小结 + 判定, 迁移 flytoCall 原 prompt, 升级到 3 档 tier + 0-5 intent 新模型). 4. **`POST /api/v1/flows/{name}/run`** -- 通用执行器, 返 JSON (非 SSE): 校验 input -> 渲染 prompt -> 每请求建引擎 -> 跑一次 -> 校验 output -> 返 `{flow_id, flow_version, trace_id, verdict, output}`. **结构化输出 enforcement** (INF-2): 靠两层确定性闸, 都由 output_schema 驱动 -- (1) in-loop `JSONSchemaValidator` 反射器自纠 (MaxTurns 限) + (2) REST 层返回前最终校验 (失败报 `verdict=invalid_output`). **provider 原生 `json_schema` (`Config.JSONSchema`) 刻意不接** -- 真跑发现它在默认 Anthropic (main.go:417 `runInstance = TypeAnthropic`) 上会发当前 API 拒收的 `anthropic-beta: structured-output-...` header (HTTP 400), 直接打挂而非仅降级 (quotedispatch 早知此事, 有 `MainDisableJSONSchema` 按 provider 关它). flow 侧按 provider 能力有条件接第 1 层是 P1 优化 (减反射器 retry), P0 不做, 两层确定性闸已在每个 provider 上扛住保证. **provider 来源纪律** (v2 修订, PM 拍板 2026-07-12): flow 的 provider **只能**来自请求 `provider_instance` 点名的 ADR-0017 命名实例 -- 即消费者自己拥有的那条配置 (经 `PUT /api/v1/config/instances/{name}` 建, 如 `flytocall-deepseek`). **不存在任何平台默认兜底**: v1 的 `FlowConfig.DefaultProvider` 回落与 RoleRun 路由优先都已去掉, 因为两者都让消费者静默骑上不属于自己的 key 配置 (谁在花钱说不清 / 轮换互相误伤 / 租户隔离无从谈起). fail-loud 矩阵: 运行时引擎配置禁用 -> 503 (指向 `FLYTO_SECRET_MASTER_KEY`); `provider_instance` 缺失或未知 -> 400 (报文指向建实例的端点); model 请求与定义都没给 -> 400. **隔离按配置条目, 不按 key 值**: 两个消费者可以持同一串 key, 但必须各自拥有独立实例, 轮换/审计/归属互不影响. model 优先级: 请求 `model` > `Definition.Model` > 400. 这与 ADR-0005 引擎中性 (引擎不自带 provider, 调用方给什么用什么) / ADR-0006 fail-loud / ADR-0017 命名实例是同一条线; 病根本就在平台层偷懒挂默认, 不在引擎. **归属边界** (依 flytoCall 自己的需求 §4.1): flow 定义 (prompt + schema + 模型选择) 由 CCM 拥有, 仓内注册; 消费方每请求传 runtime input + 活口径 taxonomy, **不经 HTTP 注册 prompt**. 动态 taxonomy 约束 (tags 取自 valid_tags / reason 取自 invalid_reasons) 是每请求变化的, 无法进静态 schema, 故走 prompt 指令层 + flytoCall 侧 `validateVerdict` 最终校验; CCM 的 output_schema 只锁静态形状. ## 3. 替代方案 / Alternatives - **同 PR 把 quotedispatch 收敛到 enginefactory** (advisor 首选): 消除两处 flag 复制. **否决 (本轮)**: quotedispatch 测试用 `fakeEngineRunner` 短路真引擎构造, **不覆盖 `engine.New` 的 flag 集**, 收敛的正确性只靠肉眼 diff 无测试兜底, 在 billcost 收入路径上风险不值当. 记 tracked follow-up (收敛前先补 Config 等价性 guard). - **消费方经 HTTP 自助注册 prompt** (flytoCall §5.2 提的): 与它自己 §4.1 "prompt 归 CCM" 矛盾, 且开 prompt 注入 / 治理口子. 延后到 DB 背书自助注册增量; `Registry` 接口是其接缝. - **只修 `/agent/run` 让 SystemPrompt/Tools 生效**: 那是模型 A (共享引擎, 无租户隔离, 完整默认工具集). flytoCall 明确要模型 B 的隔离 + 版本化 + 审计. 具名 flow 才给厚接入的完整价值. - **手写 JSON Schema 校验器** (省一个 dep): wrapup 需 enum + 整数范围 + 条件必填 (if/then), 手写 if/then 是坑. 用成熟库 (dep 关在 platform 层, 不碰 core zero-dep 铁律). - **statically 编码动态 taxonomy 到 output_schema**: 做不到 (每请求变), 见 §2 边界. ## 4. 影响 / Consequences - **正面**: 加 call-center flow (标签建议 / NBA / 阶段推断 ...) 变成加一个 `Definition`, 不再手写 handler. `responseguard` + `/flows` 两个悬空面接到真消费者. 每请求引擎天然完全租户隔离, 绕开模型 A 的隔离边界 (core/TODO.md L910). flytoCall 删掉 `fmt.Sprintf` 拼 prompt + `strings.Index` 抠 JSON. - **负面 / 债务**: enginefactory 与 quotedispatch 的 model-B 装配暂有两份 (记 tracked follow-up 收敛). P0 只支持零工具 flow (`tools.None()`); 按名工具收窄 (RAG / 业务工具) 需运行时工具注册表 (INF-3, P1). `idempotency_key` 接收 + 回显做关联但不去重 (真幂等需 dispatch 持久化, INF-4, P2). flow 执行无持久化审计 (INF-4). - **命名空间**: `POST /flows/{name}/run` (具名执行器, name-keyed) 与现有 `/flows/{id}` CRUD stub (node-graph draft, id-keyed) 段数 + 方法都不同, 无路由冲突; 语义上是两种东西, P0 不统一 (node-graph 可视化搭建器另论). ## 5. 验证 / Validation - 单测 (scripted provider, 确定性 CI 覆盖): `responseguard` (enum / 范围 / **条件必填 if-then** / 拼接 / 围栏容忍全覆盖), `flow` (registry prepare / 输入校验 / prompt 渲染带活口径 / 输出校验), `enginefactory` (happy + 必填拒绝), `internal/server` flow handler (404 未知 flow / 400 畸形 input / 200 verdict=ok / 200 verdict=invalid_output / 502 引擎错 / 503 未接线 / idempotency_key 回显 / 活口径进 user prompt; v2 增: 503 运行时配置禁用 / 400 provider_instance 缺失或未知 / 400 model 缺失 / 断言引擎真跑在点名实例 provider + 请求 model 上). 全 `-race` 绿. - quotedispatch (未改) + server 全量 `-race` 绿, 证明模块变更 (加 jsonschema dep) 没碰坏收入路径. - **真跑闭环 (对照真值, 非 LLM 自评, feedback_verify_against_truth)**: `flows_live_test.go` (`//go:build live`, key gate) 对真 Anthropic 跑通 `callcenter.wrapup` -- 走真 `defaultFlowEngineRun` -> `BuildStructuredEngine` -> `eng.Run` drain -> reflector -> `ValidateOutput` (scripted 测试 override seam 覆盖不到的唯一路径). **真跑抓出并修了一个真 bug**: 设 `Config.JSONSchema` 让 Anthropic 发被拒的 `anthropic-beta` header HTTP 400 (见 §2, 层 1 刻意去掉). 修后输出对照真值验证: tier=valid (正确判定库存咨询) / intent=3 / tags=["库存咨询"] (**从活口径选取未自造**) / note 忠实整合坐席要点无编造 / quality_score=45 + 合理扣分理由. ## 6. 触发重新评估的条件 / Trigger conditions - flytoCall 要第一个带工具的 flow (kb.search RAG) -> 触发 INF-3 (工具注册表 + embedding/vector 栈), 届时重审 enginefactory 的 `ToolPolicy` 接缝是否够. - 出现第二/第三个消费者要自助注册 flow -> 触发 DB 背书 `Registry` 实现 + 注册治理 (谁能注册 / prompt 审核) 决策. - 多个 flow 需持久化审计 (合规 / QA 回放) -> 触发 INF-4 (dispatches 真持久化 + trace 关联 + idempotency 去重). - quotedispatch 下次要动 `BuildMainEngine` 的 flag -> 借机收敛到 enginefactory (先补 Config 等价性 guard). ## 7. 工程量 / Engineering footprint 新增 4 包/文件: `enginefactory/` (factory + test), `responseguard/json_schema_validator.go` (+ test), `flow/` (definition + registry + callcenter + test), `internal/server/flows_run.go` (+ scripted test + `flows_live_test.go` `//go:build live`). 改 2 处: `server.go` (加 `flowCfg` 字段 + 1 路由), `cmd/common/main.go` (import + `AttachFlows` 接线). 加 1 dep: `github.com/santhosh-tekuri/jsonschema/v6` (platform 层) + swagger 重生. 零 core 引擎改动. ## 8. 修订记录 / Revision history - 2026-07-12 v1 Accepted: P0 落地 (INF-1 具名 flow 注册 + 按名执行, INF-2 REST 层结构化输出), 首个 flow `callcenter.wrapup`. INF-3 (RAG/工具) / INF-4 (审计持久化) / DB 自助注册 / quotedispatch 收敛记 follow-up. - 2026-07-12 v2 provider 来源纪律: 去掉 `FlowConfig.DefaultProvider` 兜底与 RoleRun 路由优先; 请求必须 `provider_instance` 点名消费者自己的 ADR-0017 实例, model 走 请求 > 定义 > 400; 缺失一律 fail loud (见 §2 "provider 来源纪律"). prod 启用运行时配置 (设 `FLYTO_SECRET_MASTER_KEY`) + flytoCall 建自己的 deepseek 实例是部署侧 follow-up. - 2026-07-16 v3: 消费者点名的 failover (PM 拍 "优先本地, 本地不可用才云端"). FlowRunRequest 增 `fallback_instance` + `fallback_model` (可选, 成对): 主选 provider 级失败 (连接/上游 HTTP 错误) 时切换**一次**到备选. 不违反 v2 来源纪律 -- 备选同样是消费者自己的实例, 平台只代为切换, 绝无平台默认. invalid_output 不切换 (模型已应答, 质量结局重跑白花钱). 备选提前解析 (写错在主选白跑前 400). 响应增 `served_instance`, 审计行记真实服务方 + 可见 failover 注记. 典型用法: 主选 m5max/gemma4 (本地, 质量+零成本), 备选 flytocall-deepseek (云端兜底). - 2026-07-16 v4 (R3 落地, PM 拍 "给我运维设置界面, 消费者调用就行"): 自助 flow 注册表 -- `flows` 表 (租户作用域, draft -> published 生命周期) + `flowstore` (pg/inmem) + 写面 (PUT /flows/{name} 存草稿 / POST /flows/{name}/publish 发布 / DELETE) + GUI 编辑器 (/flow 页表单: prompt/输入输出 schema, 输出 schema 即反射器定义). 发布闸 = flow.Prepare (与执行器同源, 发布即可跑); 执行解析链 = 内建优先 (防覆盖) -> 租户已发布行; 草稿不可调; 跨租户不可见. 多 agent team 编排不在本轮 (节点画布另开主题).