用 C# 批量调 AI 接口时,最容易踩的坑不是语法,是一上来就开一堆 Task 把接口打限流,或者一个超时就把整批任务拖死。这篇按工程落地顺序讲:并发度怎么定、超时和重试怎么分档、失败怎么降级、日志该留哪几个字段、上线前怎么小规模验证,最后给一份出问题时的排查顺序。
用 C# 批量调 AI 接口,翻车现场基本长一个样:以为开了几十个 Task 就能快,结果第一批就撞上对方限流;或者某一两个请求卡住不返回,整批 WhenAll 一直挂在那。语法本身没错,错的是没先把并发这件事当成工程问题来设计。
这篇按落地顺序走一遍:并发度怎么定、超时和重试怎么分档、失败往哪降、日志留哪几个字段、上线前怎么小规模试水,最后给一份出问题时的排查次序。不承诺任何一套参数放之四海皆准,接口上限和线路质量变了,结论就得跟着调。
先定并发度,再写代码
并发度不是拍脑袋定的,得先从对面倒推。先搞清楚目标接口允许的 QPS 或并发连接上限,站内 /qps-concurrency-limits-dev 有口径说明,具体数字到 /ai-nav/category/ai-model-platform 逐家核对当时的值。拿到上限后乘一个安全系数,才是你能用的调用方并发数。
- 本地小团队从个位数并发起步,跑稳一两轮再加,别一上来就对齐上限
- 用 SemaphoreSlim 做闸门,控制的是同时在跑的请求数,不是 Task 的创建数量
- 给每批任务设总量上限,队列堆到几十万条内存先炸,不是接口先炸
- 对方是中转站时并发口径常和官方不一样,/sites 里的检测信息能帮你判断它扛不扛得住你的量
var gate = new SemaphoreSlim(maxDegree);
var tasks = items.Select(async item => {
await gate.WaitAsync(ct);
try { return await CallAsync(item, ct); }
finally { gate.Release(); }
});
var results = await Task.WhenAll(tasks);超时、重试与退避怎么配
HttpClient 的默认超时对批量场景偏宽,一个慢连接能把整批拖住。做法是分两层:连接超时管握手快慢,整体超时管一次调用最多花多久,两个都显式设。具体秒数档位可以参照 /api-timeout-tuning 的分档思路,别直接抄某个固定值,网络环境不同结论不同。
| 错误类型 | 是否重试 | 处理动作 |
|---|---|---|
| 连接失败 / 超时 | 是 | 计入退避队列,重试有次数上限 |
| 5xx 服务端错误 | 是 | 先小退避,连续多次则切备用线路 |
| 429 限流 | 是,但要降温 | 降并发加退避,不是原地硬重试 |
| 401 / 403 鉴权 | 否 | 先走 /401-auth-error-troubleshoot 排查,重试没意义 |
| 400 参数错误 | 否 | 回看请求体,重试一万次也还是错 |
- 退避用指数增长加随机抖动,避免一批任务在同一秒集体重试,把对方再打一遍
- 重试次数要设死上限,超过就丢进失败队列,不要让单个请求无限循环
- 每个请求挂 CancellationToken,整批结束时能干净取消掉还在跑的请求,别让线程池被占住
- 换线路或换 Key 后先单独测通再合进批处理,参考 /relay-switch-api-verification 的验证思路
降级路径与日志字段
降级不是把失败的请求吞掉,而是准备好备用模型或备用线路,主线路连续错误到阈值才切。切换这个动作必须留痕,哪一批、从哪条切到哪条、切之前错误码是什么,都要记下来,别做成静默替换,否则线上表现会变得没人看得懂。
| 日志字段 | 为什么记 |
|---|---|
| 请求 ID | 把一次调用从发起串到结果,便于单点复盘 |
| 模型名 / 线路 | 区分是某家出问题还是全线出问题 |
| 批次号 | 能按批看成功率,定位是哪一秒开始变坏 |
| 耗时 | 区分慢和错,慢请求往往先于报错出现 |
| HTTP 状态码 | 限流、鉴权、服务端错误的直接证据 |
| 是否重试及次数 | 判断退避配置是否在起作用 |
异常消息不够用,响应体里通常带错误码,截一段存下来。限流和参数错的响应体长得不一样,只记 Exception.Message 会把这两类混成一条。失败任务落盘或进表,支持单独重投,别整批重跑;重投前先确认原请求是否幂等,不幂等的接口重投要人工确认。
上线前验证与排查顺序
- 小批量试水先跑几十个请求、低并发一轮,看成功率、平均耗时和错误分布,再决定放量节奏
- 逐步加压并发翻倍跑一轮,观察错误率拐点出现在哪一档,把拐点前的位置当生产并发上限
- 单请求复验怀疑代码时先把并发闸门调到 1 跑一遍,能复现说明是请求本身的问题,不能复现才往并发控制上查
- 按序排查单请求能否成功 → 并发降到 1 是否正常 → 是否集中报 429/5xx → 是否只有某条线路出问题 → 最后才怀疑代码
常见问题
C# 里用 Task.WhenAll 批量调接口,为什么会大面积超时?
通常是并发数超过了对方上限,请求在服务端排队,最后一起超时。先确认接口的并发口径,用 SemaphoreSlim 把同时在飞的请求压到合理数量,并给每个请求单独设超时和取消令牌,而不是让整批任务共用一个等待。
同步 HttpClient 和 async/await 混着用会有什么问题?
混用容易造成线程池饥饿:同步等待会把线程占住,async 任务拿不到线程继续跑,表现就是程序明明没干多少活却很慢。并发调接口的场景建议从入口到出参整条链路走 async,别在中间用 .Result 或 .Wait() 卡住。
怎么判断是本地并发写错了,还是对方限流?
把并发降到 1 单独跑同样的请求:能稳定成功,说明代码写法有问题;仍然失败且返回限流类状态码,基本就是对端限制或线路问题。也可以只换一条线路、Key 不变再跑一遍,对照结果就能分清是哪一侧的事。