这段时间接了个舞蹈视频生成的小项目,需要对接 SeeDance 平台的 Tasks API。原本以为跟普通 REST 接口一样,传参、等待、拿结果三步走,结果第一次调用就给我上了一课——返回的不是视频地址,而是一个task_id。如果之前没接过异步任务类的接口,第一次确实容易懵。这篇文章就把我从文档梳理、签名调试、任务提交、轮询、回调接管,到最后上线的整个过程写下来。尤其是那些文档里不会写、只有真实调用才会踩到的细节,我会尽量都交代清楚。
SeeDance 的 Tasks API 本质上是一套标准的异步任务接口,适合那种请求耗时较长、无法在单个 HTTP 周期内返回最终结果的服务。比如生成一段舞蹈视频,通常需要几十秒甚至几分钟,如果同步接口一直挂着连接,网关层、代理层、客户端都会面临超时压力。所以平台把一次完整的生成过程拆成"提交任务—查询进度—获取结果"三个阶段,所有交互都围绕任务 ID 展开。理解这套模式之后,你不光能对接 SeeDance,以后遇到其他类似的任务型 API(视频渲染、模型训练、批量处理)都能很快上手。
1. 为什么 SeeDance 要绕过同步请求,让你用"任务"去做事
1.1 视频生成的耗时长,逼着接口改变交互范式
先看一个具体场景:你传了一段音频和一套动作标签给 SeeDance,让平台生成一段 30 秒的舞蹈视频。这个任务背后牵扯到音频特征分析、动作序列生成、姿态合成、视频渲染等多个步骤,任何一步都可能需要处理大量数据。如果把整个计算过程塞进一次 HTTP 请求里,服务端必须长时间占用连接和进程资源,一旦并发上来,连接池很快就被占满。同步请求还会带来一个难以忍受的问题:客户端如果设置了 60 秒超时,而你生成一个视频需要三分钟,那客户端会直接断掉,但服务端并不知道,于是任务继续在后台执行,最终结果无人领取,等于白算。
异步任务模型则完全不同。发起请求时,服务端只负责校验参数、创建任务记录,然后立即返回一个任务 ID。真正的计算过程放到后台队列里慢慢执行。客户端拿着任务 ID 轮询进度,或者等服务端主动回调通知,整个过程不会卡住任何一个请求。这个思路跟去餐厅点外卖很像:你下单之后拿到一张小票,厨房慢慢做,你在家里等骑手通知,而不是在柜台前一直盯着厨师炒菜。
1.2 任务型接口的标准生命周期:提交、运行、结束
理解 Tasks API 的关键,是先把任务的生命周期刻进脑子里。根据我接触到的 SeeDance 接口版本,一个任务会经历下面几个状态:
- PENDING:任务刚提交,等待队列调度。
- RUNNING:任务已在执行中,期间会产生进度百分比。
- COMPLETED:任务成功结束,此时可以获取结果。
- FAILED:任务执行出错,返回错误码和错误描述。
- CANCELED:任务被用户主动取消。
整个生命周期是单向流转的,一旦进入终态(COMPLETED、FAILED、CANCELED),就不会再变。开发时需要特别注意:不要自己臆造状态。有些人会把"任务不存在"理解为一种状态,实际上那应该用 404 错误去表达,而不是一个状态字段。状态机设计得越简单,后续的持久化和异常处理就越不容易乱。
2. 对接前的环境准备:鉴权方式与最容易被忽略的沙箱配置
2.1 拿到凭证后先别急着调接口,先确认这三件事
我在对接 SeeDance 之前,先花了不少时间在凭证和环境的确认上。很多人拿到文档第一件事就是复制示例代码,然后一跑就报错,再回头查文档,效率其实很低。建议按下面的顺序把基础信息理清楚:
接口 Base URL:Sandbox 环境和生产环境通常不是一个地址。如果你只在沙箱里调试,调通了就直接换生产地址,很容易因为沙箱与生产之间配置差异而踩坑,比如生产环境的 IP 白名单、回调地址限制、访问密钥权限都不同。我通常会列一张环境清单,把 Base URL、API Key、App ID 分开记。
鉴权方式:SeeDance 的 Tasks API 我接触的版本用的是"请求头签名"方案。请求时需要在 Header 里带上
app_id、timestamp、nonce、signature四个字段。签名规则是官方定的,一般是用 App Secret 对app_id + timestamp + nonce拼接的字符串做 HMAC-SHA256,然后转十六进制。拿到签名示例后,一定要先在本地用同一个入参算一遍,确认结果跟文档里一致,否则后面所有请求都会 401。请求体格式与字符编码:提交任务的 Content-Type 通常是
application/json。所有文本参数必须用 UTF-8 编码,如果原始素材里有中文字幕、特殊符号,在构造 JSON 字符串时尤其要小心,尽量使用语言自带的 JSON 库序列化,不要手拼字符串。
2.2 签名算法里的几个细节,搞错了直接 401
签名计算本身不难,但细节非常多。最常见的问题有三个:时间戳不是秒级、nonce 重复使用、以及签名拼接顺序跟文档不一致。
时间戳我习惯统一用秒级 Unix 时间戳,也就是 10 位数字。如果你用毫秒级 13 位数字,服务端校验时如果刚好允许 ±5 分钟误差,某些边界情况下也可能通过,但建议严格按照文档来,避免在团队协作时产生歧义。nonce 是指一次性随机字符串,每次请求都要不同。签名请求的防御逻辑就是要防止重放攻击,如果同一个 nonce 被使用两次,服务端会直接拒绝。nonce 生成我用uuid4().hex截取前 16 位,足够满足随机性要求。
再看签名拼接。我用 Python 举个例子,假设官方规则是把app_id、timestamp、nonce按顺序拼接,再用 App Secret 做 HMAC-SHA256:
import hashlib import hmac import time import uuid def generate_signature(app_id: str, app_secret: str, timestamp: int, nonce: str) -> str: message = f"{app_id}{timestamp}{nonce}".encode("utf-8") key = app_secret.encode("utf-8") return hmac.new(key, message, hashlib.sha256).hexdigest() app_id = "your_app_id" app_secret = "your_app_secret" timestamp = int(time.time()) nonce = uuid.uuid4().hex[:16] signature = generate_signature(app_id, app_secret, timestamp, nonce) headers = { "Content-Type": "application/json", "app_id": app_id, "timestamp": str(timestamp), "nonce": nonce, "signature": signature, }这里有一个容易忽略的点:timestamp和nonce在签名字符串里是原始值,但在 Header 传输时必须转成字符串。如果你用整数时间戳参与签名,服务端拿到的却是字符串,两边算出来的就不一致,直接 401。我建议签名函数参数全部声明为字符串类型,内部不折腾隐式转换,能从源头减少这类问题。
3. 提交一个 SeeDance 任务的参数设计,以及为什么建议先写一个调试脚本
3.1 任务请求体的核心字段
SeeDance Tasks API 的提交接口一般是POST /v1/tasks。请求体里常见的字段跟我对接的版本差不多,下面这张表可以作为一个参考:
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| task_type | string | 是 | 任务类型,比如dance_video_generation |
| audio_url | string | 是 | 音频素材的公网可访问 URL |
| motion_tags | array | 是 | 动作标签列表,比如["hiphop", "up_tempo"] |
| duration | int | 否 | 生成视频时长,单位秒,默认 30 |
| resolution | string | 否 | 分辨率,如1080p |
| frame_rate | int | 否 | 帧率,默认 30 |
| callback_url | string | 否 | 状态变更通知的回调地址 |
| meta | object | 否 | 透传字段,客户端自行定义,原样返回 |
把参数设计理解透彻,对后续调优帮助很大。比如callback_url,它不属于任务本身的计算逻辑,但它决定了你以轮询还是回调的方式接收结果。如果你只是想快速联调,这个字段可以先不填,用轮询就好。如果你正准备上生产环境,建议第一版就配上回调,因为回调在任务终端状态产生时立刻触发,比轮询更实时,也减少无谓的请求。
另外meta字段可以放业务侧的任务流水号、用户 ID、业务来源等。很多团队一开始会忽略这个字段,导致任务完成后无法定位是哪笔业务触发的生成。SeeDance 平台会把这个字段原样透传,不会参与计算,非常实用。
3.2 上传输入素材的两种方式及其取舍
提交任务时,音频文件要么以 URL 形式传给 API,要么先通过文件上传接口把素材传到平台,拿到一个素材 URL 再提交任务。我第一次图省事,直接把外部存储的临时 URL 填进audio_url,结果任务一直停留在 PENDING,最后查后台日志才发现是下载外部素材超时。
这里给大家一个判断标准:如果音频文件体积不大(比如几 MB 以内),而且你所在环境外网访问稳定,直接填 URL 确实方便。但如果文件较大、公网访问不稳定,或者文件属于敏感数据,最好先走文件上传接口。SeeDance 的文件上传会返回一个平台内部的素材标识,后续任务运行时从内部存储拉取,速度快且不依赖外网环境。代价是多一次上传请求和额外的有效期管理,你需要把素材 ID 和任务 ID 的对应关系保存下来,方便排查问题。
4. 查询任务进度:轮询不是无脑循环,要讲究节奏
4.1 轮询接口的返回结构
提交任务后,响应体里会有一个task_id,形如task_20240530120000_abcd1234。拿到它之后就可以调用GET /v1/tasks/{task_id}查询进度。返回结构大致如下:
{ "task_id": "task_20240530120000_abcd1234", "status": "RUNNING", "progress": 45, "result_url": null, "error": null, "meta": { "biz_trace_id": "order_12345" } }progress是一个 0 到 100 的整数,表示整体进度百分比。result_url只有在 COMPLETED 状态下才会赋值为结果视频的可下载地址。error在 FAILED 状态下会返回对象,包含错误码和错误信息。这里建议你提前设计一个统一的任务状态对象,把接口返回的数据映射到内部模型,方便后续做持久化和界面展示。
4.2 指数退避轮询的简易实现
轮询间隔切忌固定不变。任务刚开始几秒钟状态可能没变化,但如果你每 200 毫秒查一次,既浪费请求配额,又可能触发平台的限流策略。我采用的是指数退避策略:第一次等待 1 秒,之后每次等待时间乘以 1.5,最大不超过 5 秒。
import time import requests def wait_and_query(task_id: str, max_wait: float = 5.0): interval = 1.0 while True: resp = requests.get( f"https://openapi.seedance.example.com/v1/tasks/{task_id}", headers=headers, timeout=10, ) resp.raise_for_status() data = resp.json() status = data["status"] if status in ("COMPLETED", "FAILED", "CANCELED"): return data time.sleep(interval) interval = min(interval * 1.5, max_wait)轮询终止条件建议增加一个整体超时上限,比如生成任务最长不超过 10 分钟。如果超过这个时间还没进入终态,就应该把任务标记为异常,主动联系平台排查,而不是无限轮询下去。
4.3 关于状态流转的注意事项
状态字段显示RUNNING时,progress可能是 0,也可能是 50,这取决于平台内部落盘进度。我不建议把progress当成一个严格线性的指标来展示给用户。它更像是一个"有变化"的信号,只要在动就说明任务还在执行。
另外需要注意,不要对状态值做大小写模糊匹配。我见过有人把completed和COMPLETED混着比较,在测试环境成功,到了生产环境因为平台升级返回了统一大写就匹配不上,任务一直卡在"未知状态"。稳妥的做法是统一转小写再比较,养成习惯。
5. 用回调替代轮询:绑定 webhook 的完整流程与坑
5.1 注册回调地址时需要明确的几个约定
如果不想频繁轮询,可以在提交任务时把callback_url参数填成你的公网接口地址。任务状态每次发生变更,平台都会向这个地址发送一个 POST 请求。注册回调前,有几件事必须先确认:
回调地址必须外网可访问,且必须是 HTTPS。很多本地联调的人一开始填
http://localhost:8000/callback,结果平台根本不可能访问到你本地。建议内网穿透工具只用于临时调试,生产环境务必使用网关提供的公网域名。回调消息格式和事件类型要跟平台确认清楚。不同平台的回调体设计差异很大,有的包一层
event字段,有的直接就是任务对象。我接触的版本大致是:
{ "event": "TASK_STATUS_CHANGED", "task_id": "task_20240530120000_abcd1234", "status": "COMPLETED", "result_url": "https://cdn.seedance.example.com/result/xxx.mp4", "timestamp": 1717156800 }- 响应要求:回调接收方必须在短时间内返回
200 OK。如果返回其他状态码或超时,平台会根据重试策略多次重发。你的回调接口要尽量做成快速的"接收确认"动作,把耗时操作放到消息队列里异步处理。不要在回调里直接下载视频、写数据库、调三方接口一气呵成,一旦超时,平台重试又会导致重复消费。
5.2 回调消息的验签与幂等处理
回调接口暴露在公网上,第一件事就是验签。验签逻辑和请求签名类似,也是通过请求头里的签名信息对回调消息体做校验。具体字段和算法以官方文档为准,但有一个思路是通用的:把回调请求体原始字节流取出来,拼接上签名密钥做 HMAC,比对结果与请求头中的 signature 是否一致。
验签之后就是幂等处理。平台可能因为网络抖动重复推送同一条回调,你的接口需要用一个全局唯一字段(比如task_id + status)作为幂等键。收到回调时先查一下本地任务记录是否已经进入终态,如果已经是终态,直接返回成功,不再重复处理。我把这个逻辑做成一张小的状态变更表,专门记录每个任务的状态流转历史,既保证幂等,也能留痕。
def handle_callback(payload: dict): task_id = payload["task_id"] status = payload["status"] cache_key = f"{task_id}:{status}" if redis.set(cache_key, "1", nx=True, ex=3600): process_task_status(task_id, status, payload) return {"ok": True}使用 Redis 的SET NX做幂等标记非常方便,同一状态的重复回调在首次处理后就会被拦截。这里注意ex过期时间要大于平台最长重试窗口,我一般设置为 1 小时。
6. 一个最小可运行的对接示例:从提交到拿结果全程跑通
6.1 Python 示例代码
纸上谈兵再多,不如跑通一个最小示例。下面的代码用 Python + Requests 演示了从提交任务到轮询结果的完整流程,签名逻辑和前面的代码是一致的。为了简洁,我把凭证写在了代码里,实际项目请从环境变量读取。
import hashlib import hmac import json import time import uuid import requests APP_ID = "your_app_id" APP_SECRET = "your_app_secret" BASE_URL = "https://openapi.seedance.example.com/v1" def sign_headers(): timestamp = int(time.time()) nonce = uuid.uuid4().hex[:16] message = f"{APP_ID}{timestamp}{nonce}".encode("utf-8") signature = hmac.new( APP_SECRET.encode("utf-8"), message, hashlib.sha256 ).hexdigest() return { "Content-Type": "application/json", "app_id": APP_ID, "timestamp": str(timestamp), "nonce": nonce, "signature": signature, } def create_task(): payload = { "task_type": "dance_video_generation", "audio_url": "https://your-storage.example.com/audio/beat.mp3", "motion_tags": ["hiphop", "up_tempo"], "duration": 30, "resolution": "1080p", "frame_rate": 30, "meta": {"biz_trace_id": "order_20240530_001"} } resp = requests.post( f"{BASE_URL}/tasks", headers=sign_headers(), data=json.dumps(payload), timeout=15 ) resp.raise_for_status() return resp.json()["task_id"] def query_task(task_id): resp = requests.get( f"{BASE_URL}/tasks/{task_id}", headers=sign_headers(), timeout=10 ) resp.raise_for_status() return resp.json() def main(): task_id = create_task() print(f"task_id: {task_id}") while True: data = query_task(task_id) status = data["status"] print(f"status: {status}, progress: {data.get('progress')}") if status in ("COMPLETED", "FAILED", "CANCELED"): return data time.sleep(2) if __name__ == "__main__": result = main() print(json.dumps(result, ensure_ascii=False, indent=2))这段代码在沙箱环境跑通后,我建议先不做任何封装,直接在命令行里观察整个流程。确认任务能从 PENDING 走到 COMPLETED,再考虑抽象成服务。
6.2 运行过程中常见的两类异常
我实际跑的时候遇到过两类问题。第一类是requests.exceptions.ReadTimeout。提交任务时如果音频 URL 连接缓慢,接口在创建任务阶段就会去拉取素材,15 秒超时稍微有点紧张,我会把提交超时调到 30 秒,但前提是任务接口本身有合理的重复提交保护。如果连续超时,要去排查网络是不是连不上素材服务器,而不是盲目加超时。
第二类是 HTTP 4xx 错误。最常见的是 422 参数校验失败。平台返回的错误信息里会明确指出哪个字段不符合规范,比如duration超出范围、motion_tags里有不支持的标签。这个时候别急着改请求头,先把 payload 里的字段仔细跟文档对一遍。我曾经把frame_rate写成字符串"30",平台直接拒绝,改成整数30就好了。类型严格也是一种安全策略,对接的客户端自己也会受益。
7. 上线前必须处理的三件脏活:持久化、限流、任务重试
7.1 任务状态持久化:不能把所有数据都丢在内存里
联调阶段任务量小,状态放在内存里看着没事。但一旦接入生产流量,服务重启、机器宕机、任务结果丢失,都会变成事故。我在设计任务存储时只保留三张核心表:任务主表、状态变更记录表、回调投递日志表。
任务主表记录task_id、业务追踪 ID、提交时的完整请求参数、当前状态、最终结果 URL、创建时间、完成时间。状态变更记录表负责存每次状态变化的快照,方便回溯。回调投递日志表专门记录平台发来的每次回调,包括原始请求体、验签结果、业务处理结果。有了这几张表,排查问题的时候才不会两眼一抹黑。
持久化时机也很关键。提交任务接口返回task_id后,应该立刻在本地落库。如果先落库再请求,失败会有脏数据;如果先请求再落库,刚好在两步之间进程崩溃,这个task_id可能就永远没人知道了。我是先调用接口,拿到task_id后马上写入数据库,再返回给上层调用方。
7.2 并发限制与排队策略
SeeDance 对并发任务数一般有限制,比如同一 App ID 同时运行的任务不能超过 5 个。如果不做控制,业务高峰期一次性提交 50 个任务,平台可能会拒绝多余的请求,或者已有任务排队等待,导致整体完成时间变长。
我建议在业务侧实现一个简单队列:提交任务时不直接调 API,而是先把任务放入本地队列,由调度器按照配置的并发上限逐个提交。调度器还要处理任务的依赖关系,比如某个任务完成之后才能触发下一个任务,这个在队列里要做好状态机映射。用了队列之后,请求量和完成速率变得非常稳定,不会因为一次活动流量的激增把任务全部打回。
7.3 重试与截止时间:让失败的收益最大化
任务提交和任务处理是两个阶段的失败,重试策略完全不同。
提交阶段失败,比如网络抖动、平台返回 503,可以安全重试,但必须保证接口幂等。SeeDance 的meta字段可以放一个客户端生成的幂等键,如果你不确定上次请求是否已经创建成功,提交时带上相同的幂等键,平台检测到重复请求会返回已经存在的task_id,避免重复扣费和重复创建。
处理阶段失败,就要看错误码了。如果是参数错误(比如动作标签不存在),重试一万次也没有用,应当直接标记为失败并反馈给业务方。如果是平台内部错误或暂时性资源不足,可以延迟重试。我会给任务设置一个重试上限,比如最多 3 次,每次间隔递增 30 秒。超过上限还没成功,就按失败处理,并保留原始错误信息。千万不要在回调接口里做复杂的重试逻辑,回调本身只需要可靠接收,重试由调度器统一管理即可。
另外,给每个任务设一个"截止时间"也很重要。秒级任务超时上限设置成 600 秒就够了,到点还没完成就触发告警。后台任务挂起会占用队列资源,还会让用户看到一条永远"跑路中"的记录,这种体验非常糟糕。
我在实际对接 SeeDance Tasks API 的过程中最大的感受是:异步任务接口本身并不复杂,真正的复杂度全在周边治理上。签名、提交、轮询、回调、状态存储、限流、重试,每一环单独拿出来都不难,但串在一起就是对系统设计能力的一次小考。尤其是回调验签和幂等,看起来不起眼,线上 90% 的重复处理事故都跟这两个坑有关。如果你正准备对接类似的任务型 API,建议把这篇文章里提到的细节当成一个检查清单,逐项确认,会省下不少周末时间。