Skip to content

Proposal: 接入 Fal 队列协议面,并把协议表达为显式接缝 #332

Description

@johnnyzhang-eng

Proposal: 接入 Fal 队列协议面,并把“协议”表达为显式接缝

1. Summary

网关同时提供三套协议,产品目前只接了 OpenAI 兼容面。本提案接入 Fal 队列面,并在此过程中把“协议”从 sufy.py 的条件分支提取为一个可脱网单测的接口,使此后新增一个协议是新增一个文件、而不是修改现有 adapter。

范围只到视频链路的一个任务类型:first-last-frame-to-video。其余协议面与能力留给后续提案。

2. User Stories / Motivation

需求 现状 所需能力 所在协议面
循环动作末帧接回首帧时左右腿互换(#197 交付帧取到半个步态周期,肉眼可见跳变 first-last-frame-to-video,首尾给同一姿态即闭环 Fal 队列
动作只能靠提示词描述,不可控 同一描述反复生成结果不稳定 motion-control Fal 队列
角色一致性依赖母版单图 换动作时身份漂移 reference-to-video Fal 队列

三个需求指向同一个共同点:它们都不是缺算法,而是被同一个未接入的协议面挡住GET /v1/models 只列聊天协议的模型,据它判断能力会得出“网关没有这些能力”的错误结论。

3. Current Workaround

没有可用的绕法,已经试过一次并回退:

providers/sufy.py:254 保留着一段记录——曾实现过一整套 FalQueueVideoProvider + FirstFrameUploader + 端点映射表,412 行代码、28 条测试,最终整体删除。删除理由是“从未被真实调用过”。

删除是对的,但根因不是那套实现写得不好:VideoProvider 只有 i2v(first_frame, prompt, seconds, size) -> bytes 一个方法,形状固定为“一次调用出 bytes”;Fal 面是“建单 → 轮询 → 取结果”,且鉴权头是 Key 不是 Bearer。没有承接它的接缝,所以那套代码接不进产品链路,只能躺着或删掉。

今天要接任何 Fal 面能力,只有两条路:把第三套请求形状继续塞进 sufy.py 的条件分支,或者再写一次那 412 行然后再删一次。

4. Goals

5. Out of Scope

6. Proposal

6.1 Design Rule

一条规则:协议只知道字节怎么排,不发请求、不重试、不休眠。

请求的构造与响应的解析是纯函数;发请求、轮询节奏、失败处理分别归 adapter 与 gateway/

6.2 Interface

class JobProtocol(Protocol):
    """建单 → 轮询 → 取结果。/v1/videos 与 /queue/* 差别只在路径与鉴权,形状同构。"""

    def build_submit(self, req: VideoRequest) -> HttpCall: ...
    def parse_submit(self, resp: HttpResponse) -> AdapterResult: ...
    def build_poll(self, job_id: str) -> HttpCall: ...
    def parse_poll(self, resp: HttpResponse) -> AdapterResult: ...
    def build_fetch(self, job_id: str) -> HttpCall | None: ...

HttpCall 是 method / path / headers / body 的纯数据结构。AdapterResult 沿用 #331 已定义的那个,不新造。

鉴权头由协议层产出,不由厂商层统一注入 —— Fal 面是 Authorization: Key {key},OpenAI 面是 Bearer,写错时的响应与“模型不存在”难以区分。

6.3 Examples as Specification

# 现状:请求形状写死在 adapter 里,按型号分支
# providers/sufy.py
if model in _IMAGE_LIST_MODELS:
    body["image_list"] = [{"image": b64}]
else:
    body["input_reference"] = _first_frame_datauri(first_frame, size)
job = client.post("/videos", json=body)          # 路径与鉴权也写死

# 提案:协议层只产出纯数据,adapter 照着发
call = protocol.build_submit(req)
# OpenAI 面 → HttpCall(
#     method="POST", path="/v1/videos",
#     headers={"Authorization": "Bearer <key>"},
#     body={"model": ..., "input_reference": "data:image/jpeg;base64,..."})
# Fal 面   → HttpCall(
#     method="POST", path="/queue/fal-ai/veo3.1/first-last-frame-to-video",
#     headers={"Authorization": "Key <key>"},
#     body={"prompt": ..., "image_url": "...", "end_image_url": "..."})

# 等价性:两条面产出的 AdapterResult 形状相同,gateway/ 无需区分

6.4 Boundary Cases

情形 行为
型号未在 FAMILIES 登记 RegistryError,建单前拒绝
同一 fallback 链上混入不同协议面 _validate_chain 拒绝(#331 已有判据,不变)
Fal 面的首帧字段名叫 *_url,值却可以是 base64 dataURI 两面共用同一套首帧编码,不需要 bytes → 公网 URL 的上传器
首尾帧只给了一张 退回普通 i2v,不静默补一张

7. Error Handling

条件 行为
鉴权头写错(Key 与 Bearer 互换) 401,错误信息标明协议面,不与“模型不存在”混淆
建单返回 2xx 但无 request_id INVALID_RESPONSE,不进入轮询
轮询预算耗尽 TIMEOUT,标记可能已计费,不重复建单

8. Compatibility

纯新增。ImageProvider / VideoProvider 的方法签名不变,ai_engine 与业务侧零改动。现有 OpenAI 面路径行为不变,由现有测试约束。

9. Alternatives Considered

9.1 继续在 sufy.py 内加分支

改动最小,但第三套请求形状进来后,该文件同时承担三种路径、两种鉴权、三种轮询协议。已经因此删过一次 412 行。

9.2 直接用 fal-client

Fal 队列协议有官方 Python 客户端。读过源码后不采用,理由是轮询地址的拼装规则写死,与本网关的路径结构对不上

  • 域名可换。fal_client/auth.pyFAL_RUN_HOSTFAL_QUEUE_RUN_HOST 都读环境变量,默认 fal.run / queue.fal.run。但两者在模块导入时求值成 QUEUE_URL_FORMAT,是进程级常量,不能按调用切换。
  • 路径不可换。提交走 QUEUE_URL_FORMAT + application 原样拼接,查状态与取结果走 f"{QUEUE_URL_FORMAT}{prefix}{app_id.owner}/{app_id.alias}/requests/{request_id}",即只取 endpoint 的前两段。前两段这条规则与本网关一致fal-ai/kling-video/o1/image-to-video 的轮询地址实测就是 /queue/fal-ai/kling-video/requests/{id},四个端点同规则);不成立的是 QUEUE_URL_FORMAT 那一段:它在模块导入时求值成进程级常量,/queue 前缀与域名都不能按调用切换,而本网关的两个协议面共用同一个进程。
  • 上传另有一套。REST_URL = "https://rest.fal.ai" 是字面量,不读环境变量;走本网关时上传路径无法改指。本提案只传 URL、不用它的上传,故不构成阻塞,但也说明这个客户端假定了自己在跟 fal 官方端点说话。

结论是三步轮询自实现,协议层仍按 6.2 的接口收敛 —— 换成任何一个库都不影响那个接口。

9.3 用 LiteLLM 统一

LiteLLM 提供 video_generation / video_status / video_content,其 Router 也带 retries 与 fallbacks。但其视频 provider 覆盖 OpenAI、Azure、Gemini、Vertex 与 RunwayML,不含 Fal 队列协议,因此不适用于本网关的视频链路。

10. Testing Strategy

测试项 方法
协议层构造正确 断言 build_submit 产出的 path、headers、body 字段,不发网络
两面产出同构 同一 VideoRequest 分别过两个协议,断言 AdapterResult 字段集一致
鉴权头随协议面变化 断言 Fal 面为 Key、OpenAI 面为 Bearer
循环闭合 首尾帧给同一姿态,断言末帧与首帧的姿态差低于既有 loop_seam 阈值
既有行为不变 OpenAI 面现有测试全部沿用,不修改

11. Open Questions

原先待定的「fal-client 能否指向自建 base_url」已在 9.2 给出结论。

2026-08-24 实测补两条接口事实:

  • /statusCOMPLETED 不是成功信号。 成功与失败的任务都返回 HTTP 200 + COMPLETED;成败只在取结果那一步显形(成功 200 带 video.url,失败 500 带 detail)。所以本面的 build_fetch 必须返回真的调用,而不是像 OpenAI 面那样返回 None
  • 取结果时的 HTTP 400 可能表示「还没好」。 veo3.1 与 vidu 在未就绪时取结果返回 400,响应体是 {"status":"IN_PROGRESS", ...}。按 400 一律判客户端错会把还在跑的任务当成失败,而单据已建、可能已计费。

仍待验:各端点 duration 的取值形态5 / "5" / "5s" 三种,四个端点未逐个实测)。猜错就是一次已计费的 400,故第三步接线前必须补测。

13. 实施计划

三步,每步一个 scoped issue 由 PR 关闭,本 Issue 只当路线图。

内容 落点 状态
1 协议接缝提取,OpenAI 面行为不变 #561 ← PR #562 CI 全绿,已 approve
2 Fal 队列面实现 + 脱网单测 #568 ← PR #569 CI 全绿,已 approve
3 接进型号链路,主力换 kling-3.0-turbo 待开 scoped issue 已实现,未提

第 3 步是唯一有产品影响的一步,回退方式是改回 AI_VIDEO_MODEL 环境变量,不需要回滚代码。

12. Summary of Changes

位置 改动
providers/protocol/(新增) JobProtocol 接口 + OpenAI 面与 Fal 面两个实现
gateway/registry.py FAMILIES 取值由“请求形状”改为“协议面 + 能力”,键不变
providers/sufy.py 请求构造与响应解析迁出,行为不变
测试 协议层脱网单测;OpenAI 面既有测试不变

13. 08-21 实测:动机从「补能力」变成「换现役型号」

同一只鸟、同一张首帧(#511 修复后产出:中灰底、主体占幅 42.5%)、同一条产品提示词,横评七个型号。尺度漂移=主体高占画面高的最大值相对首帧的增幅:

型号 时长 尺度漂移 上游官方价折算 人工判定
seedance-2.0 5s +0% ≈¥4.7 第二好
kling-3.0-turbo 3s +2% ¥2.4 最好
kling-v3 std 3s +3% ≈¥1.8 把「飞」做成了走路
kling-v2-5-turbo(现役) 5s +6% ≈¥2.2 一般
veo 3.1 4s +7% ≈¥5.7 强,但约 3 倍价
veo 3.1 8s +21% ≈¥11.4 时长越长越放飞
kling-v2-6 5s +27% ≈¥2.2 出杂物

这给本提案加了一个比原动机更硬的理由:原动机是三个缺失能力(first-last-frame / motion-control / reference-to-video)。现在还多一条 —— 人工判定最好的型号 kling-3.0-turbo 只存在于队列面POST /v1/videos 的 model 枚举只有 kling-v3-omni / kling-video-o1 / sora*;3.0-turbo 只在 queue/fal-ai/kling-video/v3/turbo/{mode}/image-to-video。换现役型号这件事本身就必须先有这条接缝。

13.1 时长按动作复杂度定,不写死 5 秒

漂移随时长单调上升(veo 4s +7% → 8s +21%;kling 3s 只 +2%)。所以时长是质量参数不只是成本参数:简单循环动作(走路、待机、飞行)3 秒足够且最稳,复杂一次性动作(攻击、跳跃)再加长。当前 i2v(..., seconds=5) 写死。

时长下限按型号不同:v2.5-turbo / v2.6 最短 5s;o1 / v3 std / 3.0-turbo 支持 3s。3s@24fps = 73 帧,抽 32 帧仍够,但这条要有断言。

13.2 已定:主力换 kling-3.0-turbo,v2-5-turbo 降为 fallback

这是产品决定,不是本提案的备选项。 现役 kling-v2-5-turbo 降为 fallback,主力换成
kling-3.0-turbo;实验的主力臂同步换成后者,此后新的横评以它为基线,v2-5-turbo 只作对照。

所以本提案不再是「补三个缺失能力」的可选增强 —— 不做它,主力模型就换不了,因为
3.0-turbo 只存在于队列面。

13.3 模型级 fallback 是新东西,现有 fallback 不覆盖它

gateway/routes.pyroutes_from_settingsbase_url + api_key 展开路由,fallback 是「换域名/换 key」这一维;模型维度不在其中。而这里要的是「3.0-turbo 失败时退回 v2.5-turbo」—— 换协议面、换鉴权头、换请求形状。本提案的 JobProtocol 接缝正好承接它,但「按模型排候选」这一层要显式定义,不能默认沿用路由 fallback。

13.4 两个待验(不猜)

  • 首帧传法:队列面吃不吃 dataURI 没验出来。只给 image_url 不给 prompt 时,合法 dataURI 与非法裸串返回同一个「prompt 缺失」,说明 image 在 prompt 之后才校验。若只吃公网 URL,则调用前要上传对象存储(产品已有该能力)。定这条要一次真实提交
  • 音频白付:3.0-turbo 的 billing_type_description 是「可灵3.0Turbo 720P有声视频」,即使请求里传了 generate_audio: false。我们生成完丢音轨,这笔是白付的;有无声档能省则省,未查到

横评产物与逐条读数在本地归档(八条原视频 + 共同首帧 + 对照条),不入仓。

Refs #192
Refs #197
Refs #331

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions