不少 .NET 同学拿到 Key 之后卡在第一步:HttpClient 怎么写、请求体长什么样、流式怎么读、超时和并发怎么设才不被限流。网上示例要么太老,要么只贴一半代码。这篇按「能跑起来」的顺序给一份 C# 落地方案,包含最小调用、流式解析、超时并发取舍和排错清单,代码结构可直接抄进项目。
拿到 Key 之后卡住的人,大多不是不懂 C#,而是不知道请求体长什么样、流式怎么读、超时并发怎么设才不会被限流。这篇按「先跑通、再优化」的顺序给一份能直接抄进项目的结构,从最小调用一路写到排错清单。
示例代码只保留必要字段,模型名、价格这类会变的东西不写死,需要时去站内页面核对当时的数字。整套思路围绕一个原则:先用最简单的方式确认链路通,再往上加流式、重试和日志。
最小可跑:一个异步方法把请求发出去
第一步别碰流式,先用普通 POST 拿到完整 JSON。请求体按 OpenAI 兼容结构组织,先只放 model、messages、stream 三个字段就够,多余参数等跑通再加。模型名不要凭记忆写,去站内模型资料库看当前可选值,换模型时改配置而不是改代码。
// 注册一次,全局复用
builder.Services.AddHttpClient("deepseek", c =>
{
c.BaseAddress = new Uri("https://api.deepseek.com/");
c.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("DEEPSEEK_KEY"));
});
var payload = new
{
model = "<去站内模型页核对当前模型名>",
messages = new[] { new { role = "user", content = "你好" } },
stream = false
};
var resp = await http.PostAsJsonAsync("chat/completions", payload);
var body = await resp.Content.ReadAsStringAsync();
Console.WriteLine(body);- 用 IHttpClientFactory 或单例 HttpClient,别在方法体里 new HttpClient,反复创建会耗尽连接
- 鉴权头固定是 Bearer 加 Key,Key 从环境变量或用户机密读,不要硬编码进仓库
- Key 怎么申请、有没有免费额度,去站内教程页核对当时的说明
- 非流式调通再加 stream,这一步能省掉后面大半的排查时间
流式输出:C# 里怎么按行解析 SSE
流式最容易踩的坑是缓冲区:如果在发送请求时没指定 ResponseHeadersRead,HttpClient 会把整段响应读完才交给你,前端看到的就是「卡一下然后全出来」。加上这个选项之后才能边收边读。
using var req = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
Content = JsonContent.Create(payload with { stream = true })
};
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
resp.EnsureSuccessStatusCode();
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:")) continue;
var data = line.Substring(5).Trim();
if (data == "[DONE]") break;
var chunk = JsonSerializer.Deserialize<JsonElement>(data);
var delta = chunk.GetProperty("choices")[0]
.GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
Console.Write(c.GetString());
}增量内容在 choices[0].delta.content 里,字段可能整个不存在,也可能值为 null,取到就跳过,别直接拼字符串。把这段解析抽成一个方法,控制台小工具和 ASP.NET Core 接口复用同一份,别写两遍。
超时、并发与重试:别把服务打死
连接超时和整体超时建议分开设。连接慢通常是网络问题,读取慢是因为模型在生成,长回答要放宽读取侧。默认的短超时套在流式上,会把正常生成误判成失败。
| 场景 | 处理方式 | 不适用情况 |
|---|---|---|
| 连接失败或 DNS 出错 | 快速失败,指数退避重试少数几次 | Key 或路由错误时重试无意义 |
| 读取超时 | 放宽读取侧,或改流式分段处理 | 非流式且确实超时的长任务要另行处理 |
| 429 限流 | 降并发、加退避,看响应头里的恢复提示 | 并发上限按账号档位不同而不同,去站内核对 |
| 401 / 403 | 不重试,直接查认证头或权限 | 临时网络抖动导致的要看具体错误码 |
并发不是越大越好。按账号能承受的档位控制同时发出的请求数,超了就会遇到限流,触发原因和恢复节奏站内有专门一篇讲。重试只针对超时和 5xx,重试要带退避和次数上限,最好在项目里封一个统一入口,别让每个调用点自己写一套。
- 确认错误类型先看状态码,是网络层超时还是服务端返回错误码,两类处理完全不同。
- 判断能不能重试超时和 5xx 可以退避重试,认证类和参数类错误重试只是浪费调用次数。
- 限制重试次数给一个上限和递增等待,避免失败时把并发堆到更高。
- 回到日志复盘把每次请求的耗时和状态码记下来,事后才能看出是偶发还是系统性变慢。
日志与排错:出问题先看哪几个字段
第一次接入建议单独写个控制台小项目验证,跑通再往业务代码里搬。跟框架配置混在一起排查,很难分清是接口问题还是依赖注入、中间件的问题。
- 每次请求至少记:时间、模型名、耗时、状态码、token 用量、错误原文
- 400 先查请求体字段名和类型,多半是模型名写错或 messages 格式不对
- 401 查 Key 和请求头格式,403 查权限和模型可用性,两者处理动作不一样
- 超时先查网络和超时设置,别一上来就改重试次数
- 多人共用一个 Key 时,转发层加请求标识,否则日志分不清是谁的调用
token 用量和费用换算会随价格变动,别在代码里写死换算比例,需要展示时去站内价格页核对当时的数字,或者干脆只展示 token 数。团队里想比较不同接入方式的实际体验,可以用站内的速度测试工具跑一轮,看延迟和成功率,而不是凭感觉。
先让最小调用跑通,再加流式、重试和日志,每一步都只引入一个新的不确定因素。下一步把最小调用跑通后,去模型资料库确认可用模型名,再去价格页核对当时的计费口径,最后用速度测试工具对比一下你所在网络的真实延迟。→
常见问题
C# 里用 HttpClient 调 DeepSeek,一直返回 401 怎么办?
先确认请求头是不是 Bearer 加空格再加 Key,很多人漏了空格或者写成了别的字段名。再确认 Key 没带多余引号或换行,尤其是从配置文件读出来时。如果头没问题,检查是不是把 Key 用在了没开通的模型上,那会表现成 403 而不是 401。两类的区分站内有一篇专门讲。
流式输出在 C# 里读不全,最后一段丢了怎么排查?
常见原因是没开 ResponseHeadersRead,或者读完一行就 break 导致末尾缓冲没冲出来。检查循环是否只在 [DONE] 时退出,而不是遇到空行就结束。另外别用 ReadToEndAsync,那是等全部内容回来才返回,流式就白做了。可以先在控制台打印原始行看结构再写解析。
并发一高就报错,C# 这边应该怎么控制?
用 SemaphoreSlim 在调用入口限个同时执行数,值按你的账号档位定,具体额度去站内官方 API 页面核对该模型当时的限制口径。超出的请求排队而不是直接抛异常,失败后用带退避的重试补上。别在多个线程里各自 new 一个 HttpClient 无限制发。