← 返回资讯

C# 调用大模型 API 完整示例

2026-06-19

C# 调用大模型 API 主要有两条路:System.Net.Http.HttpClient(.NET 内置)直发 HTTP,或通过 OpenAI NuGet 包使用官方 SDK。两者均支持 OpenAI Chat Completions 格式,改 BaseAddress 即可兼容国内各平台。

如果你是在 ASP.NET Core 后端里接大模型(比如给企业内部系统加个智能客服接口,或者给 Blazor 前端套一层 BFF),两条路都能走通,但选错了后面加班改代码的是你自己。HttpClient 直发适合两种场景:一是团队已经有一套基于 IHttpClientFactory 的调用规范,不想为了一个 AI 接口再引入一个 SDK 依赖;二是你要对接的平台返回格式跟标准 OpenAI schema 有细微差异(比如多了个 usage.prompt_tokens_details 字段,或者 finish_reason 的取值不一样),用 JsonDocument 手动解析比强类型 SDK 更容易兜住这些差异。SDK 那条路适合你就是标准 OpenAI 兼容接口、想要编译期类型检查、不想手写 JSON 序列化的场景——多数国内中转平台(包括力达云网关)都做了 OpenAI 兼容层,直接用 SDK 换个 Endpoint 就能跑。

环境准备

# 安装 OpenAI SDK(可选,推荐生产使用)
dotnet add package OpenAI
export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.lidayun.com/v1"

这里有个容易被忽略的点:本地开发用 export 设置环境变量图省事没问题,但部署到生产环境(尤其是 IIS 或者 Windows Service 托管的 .NET 应用)时,环境变量的作用域经常踩坑——IIS 应用池的环境变量需要单独在 web.config 或应用池设置里配,跟你在 PowerShell 里 $env:OPENAI_API_KEY 设的值根本不是一回事。生产项目更稳的做法是用 appsettings.json 配合 dotnet user-secrets(开发环境)或 Azure Key Vault / 阿里云 KMS(生产环境)管理密钥,不要图快直接把 key 写死在环境变量脚本里提交进仓库。

另外,「改 BaseAddress 即可兼容国内各平台」这句话背后的原理值得说清楚:只要目标接口的请求体(model / messages / max_tokens 这几个字段)和响应体(choices[0].message.content 这个路径)跟 OpenAI 的 Chat Completions schema 对得上,你的 C# 代码就完全不用改,换的只是 BaseAddress 指向的域名。这也是为什么现在大部分中转平台都主动做「OpenAI 兼容」——不是巧合,是故意让你迁移成本降到只改一行配置。但如果对方平台响应里 choices 数组为空、或者把 content 字段包在别的层级里,你就得回到 JsonDocument 手动适配那条路,或者等 SDK 出兼容补丁。

方式一:HttpClient(零 NuGet 依赖)

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

var apiKey  = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;
var baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL")
              ?? "https://api.openai.com/v1";

using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", apiKey);

async Task<string> ChatAsync(object[] messages, string model = "gpt-4o-mini")
{
    var payload = new { model, messages, max_tokens = 1024 };
    var json    = JsonSerializer.Serialize(payload);
    var content = new StringContent(json, Encoding.UTF8, "application/json");

    var resp = await http.PostAsync("/chat/completions", content);
    resp.EnsureSuccessStatusCode();

    using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
    return doc.RootElement
              .GetProperty("choices")[0]
              .GetProperty("message")
              .GetProperty("content")
              .GetString()!;
}

// 调用示例
var reply = await ChatAsync(new object[]
{
    new { role = "user", content = "用 C# 实现观察者模式" }
});
Console.WriteLine(reply);

这段代码在 demo 里跑没问题,但直接抄进生产项目会踩一个经典坑:using var http = new HttpClient(...) 这种写法如果放在每次请求都会执行的方法里(比如 Controller 的 Action 内部),每次调用都会 new 一个新的 HttpClient 实例并在用完后 dispose。HttpClient 内部持有 socket 连接,频繁创建销毁会导致底层 TCP 连接来不及释放就堆在 TIME_WAIT 状态,高并发下会把服务器可用端口耗尽,报 SocketException: 通常每个套接字地址只允许使用一次。这是 .NET 圈子里被聊烂了但依然年年有人踩的经典坑。正确做法是让 HttpClient 长期存活、跨请求复用——本文里的写法之所以放在顶层能跑,是因为它只 new 了一次;一旦你把这段代码搬进方法体,就必须改成通过 IHttpClientFactory 注入命名客户端,或者用 static readonly HttpClient 做单例。

第二个容易漏的坑在 JsonDocument 解析那行:doc.RootElement.GetProperty("choices")[0] 假设 choices 数组一定非空,但如果请求触发了内容审核拦截(finish_reason 返回 content_filter)或者服务端限流降级,choices 有可能是空数组,这里会直接抛 IndexOutOfRangeException,而且报错信息完全看不出跟大模型接口有关。生产代码建议先判断 doc.RootElement.GetProperty("choices").GetArrayLength() > 0 再取值,拿不到就把原始响应体打进日志,排查效率能提升一个量级。

还有一个更隐蔽的问题:resp.EnsureSuccessStatusCode() 在收到 4xx/5xx 时只会抛一个「状态码不对」的异常,看不到服务端实际返回的错误信息(大模型接口的错误 body 通常带 {"error": {"message": "...", "type": "..."}} 这种结构,里面才是真正有用的排查线索)。更靠谱的写法是先 await resp.Content.ReadAsStringAsync() 拿到 body,判断 resp.IsSuccessStatusCode 后再决定是记录错误还是继续解析,别让 EnsureSuccessStatusCode 把关键信息吞掉。

方式二:OpenAI SDK(推荐)

OpenAI NuGet 包(v2.x)支持完整类型提示、流式与函数调用:

using OpenAI;
using OpenAI.Chat;

var apiKey  = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;
var baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL")
              ?? "https://api.openai.com/v1";

var options = new OpenAIClientOptions { Endpoint = new Uri(baseUrl) };
var client  = new ChatClient("gpt-4o-mini", apiKey, options);

var messages = new List<ChatMessage>
{
    new SystemChatMessage("你是一个 .NET 编程助手。"),
    new UserChatMessage("解释 async/await 的工作原理"),
};

var response = await client.CompleteChatAsync(messages);
Console.WriteLine(response.Value.Content[0].Text);

如果你几年前用过大模型相关的 .NET SDK,可能对 Azure.AI.OpenAI 这个包名有印象——现在直接装的 OpenAI 包是官方从 Azure 专属客户端里拆分出来的通用版本,两者 API 形状很像但命名空间、部分类型(比如 ChatMessage 的构造方式)不完全一样,如果你的项目还在用旧包名,升级前建议先看官方迁移说明,不要直接覆盖 NuGet 版本号了事。选型上给个直接判断:纯调用第三方 OpenAI 兼容接口(包括力达云这类网关)用 OpenAI 包就够;如果你在 Azure 云上跑自己的 Azure OpenAI 资源、需要走 Azure AD 认证或私有网络,才需要 Azure.AI.OpenAI

ChatClient 内部本质上还是包了一层 HttpClient,所以上面提到的连接复用问题同样适用——ChatClient 应该像 HttpClient 一样长期存活(单例或者作用域内复用),不要每次请求都 new ChatClient(...)。强类型带来的好处也值得说一句:SystemChatMessage / UserChatMessage 这些类型在编译期就能帮你捕获拼写错误或者结构错误,不像 JsonDocument 那种运行时才暴露的字段名拼错问题;代价是遇到平台返回非标准字段时,SDK 可能直接反序列化失败,这时候还得退回 HttpClient 方式手动兜底。

流式输出

var stream = client.CompleteChatStreamingAsync(new List<ChatMessage>
{
    new UserChatMessage("写一首关于 C# 的短诗"),
});

await foreach (var chunk in stream)
{
    foreach (var part in chunk.ContentUpdate)
    {
        Console.Write(part.Text);   // 逐 token 打印
    }
}
Console.WriteLine();

底层发生了什么:流式接口本质是服务端用 text/event-stream 格式,每收到一个 token 就往连接里推一行 data: {...},直到最后发一行 data: [DONE]。SDK 把这套 SSE(Server-Sent Events)解析包装成了 IAsyncEnumerable<StreamingChatCompletionUpdate>,所以你才能用 await foreach 这么优雅地消费——如果你自己用 HttpClient 实现流式,需要手动读 Stream 逐行解析 data: 前缀并反序列化,麻烦不少,这也是流式场景更推荐直接用 SDK 的原因。

流式输出真正的价值在于首字节时间(TTFB,Time To First Byte)。非流式接口要等模型把整段话生成完才返回,如果回答有几百字,用户可能要等 5-10 秒屏幕上才刷出第一个字;流式接口通常 1 秒内就能看到第一个 token,体验上是质的差异,这也是为什么几乎所有对话类产品都用流式而不是一次性返回。如果你要给流式调用加「用户点停止按钮就中断生成」的功能,记得把 CancellationToken 传进 CompleteChatStreamingAsync,比如:

var cts = new CancellationTokenSource();
var stream = client.CompleteChatStreamingAsync(messages, cancellationToken: cts.Token);
// 用户点击停止时调用 cts.Cancel(),await foreach 会抛 OperationCanceledException 并优雅退出

不传 CancellationToken 的话,用户关掉页面或者点了停止,服务端的生成任务依然会跑到结束才释放资源,白白浪费你按 token 计费的额度。

超时、重试与限流

HttpClient 默认超时时间是 100 秒,ChatClient 内部同理。大模型生成长文本时,非流式请求偶尔真的会跑到 30-60 秒,把默认超时缩短容易误杀正常请求,但完全不设超时又会让线程池被慢请求占满。生产环境的经验值:非流式对话类请求设 60 秒超时,流式请求可以适当放宽到 120 秒(因为流式是持续在收数据,只要还在收就不算卡死):

using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(60) };

超时和限流报错在生产环境是家常便饭,别指望一次成功。429(Too Many Requests)和偶发的网络超时应该用退避重试兜底,手写一个简单版本比引入 Polly 更好理解原理:

async Task<string> ChatWithRetryAsync(object[] messages, int maxRetries = 3)
{
    for (var attempt = 0; ; attempt++)
    {
        try
        {
            return await ChatAsync(messages);
        }
        catch (HttpRequestException) when (attempt < maxRetries)
        {
            var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 1s, 2s, 4s 指数退避
            await Task.Delay(delay);
        }
    }
}

生产项目建议直接用 Microsoft.Extensions.Http.Polly 包配合 IHttpClientFactory 注册重试策略,功能更完整(能区分状态码、能读 Retry-After 头),这里手写版本是为了让你看清「指数退避」到底在干什么——每次失败后等待时间翻倍,避免短时间内对一个已经过载的服务疯狂重试,反而加重限流。

高并发场景下光靠重试不够,还得主动控制并发度,否则一下子甩出去 200 个请求,绝大多数会直接被限流拒掉。用 SemaphoreSlim 控制同时在飞的请求数是最简单的办法:

var throttle = new SemaphoreSlim(5); // 最多 5 个并发请求

async Task<string> ChatThrottledAsync(object[] messages)
{
    await throttle.WaitAsync();
    try { return await ChatAsync(messages); }
    finally { throttle.Release(); }
}

并发数设多少合适没有万能答案,跟你接的平台限流策略直接挂钩——如果平台文档写了每分钟多少次请求(RPM)或每分钟多少 token(TPM),先按这个算出一个安全的并发上限,再实测调整,别拍脑袋定数字。

在 ASP.NET Core 中依赖注入

// Program.cs
builder.Services.AddSingleton(sp =>
{
    var key     = builder.Configuration["LLM:ApiKey"]!;
    var baseUrl = builder.Configuration["LLM:BaseUrl"]!;
    var opts    = new OpenAIClientOptions { Endpoint = new Uri(baseUrl) };
    return new ChatClient("gpt-4o-mini", key, opts);
});

// 在 Controller 或 Service 中注入
public class ChatService(ChatClient client)
{
    public async Task<string> AskAsync(string question)
    {
        var resp = await client.CompleteChatAsync(
            new List<ChatMessage> { new UserChatMessage(question) });
        return resp.Value.Content[0].Text;
    }
}

两种方式对比

维度HttpClientOpenAI SDK
NuGet 依赖OpenAI
流式支持需手动解析 SSE内置 CompleteChatStreamingAsync
类型安全匿名对象 + JsonDocument完整强类型
DI 集成推荐 IHttpClientFactory直接注册为单例
适用场景轻量函数 / Azure FunctionASP.NET Core 生产项目

常见问题

HttpRequestException 提示证书错误怎么办? 在开发机可用 HttpClientHandler { ServerCertificateCustomValidationCallback = ... } 临时绕过;生产环境必须修复证书或配置受信 CA。

如何防止 HttpClient 资源泄漏? 不要在循环中 new HttpClient(),应通过 IHttpClientFactory 或静态/单例实例复用;SDK 的 ChatClient 本身可安全复用。

调用返回 401 怎么排查? 确认 OPENAI_API_KEY 环境变量已正确设置;在 .NET 中用 Environment.GetEnvironmentVariable 打印验证;检查 key 是否属于正确的平台(不同平台 key 不通用)。


更多接入方案见大模型 API 接入完全指南接入教程专题。Rust 版本实现参考Rust 调用大模型 API。需要统一多模型入口?申请力达云聚合 API 内测