真正开始做 AI 集成时,绝大多数 .NET 开发者的第一步都不是训练模型,而是调用现成模型服务。这样做的好处非常直接:你不必准备海量训练数据,也不需要自己维护 GPU 训练环境,只要完成身份验证、组织请求内容,并把返回结果接到现有系统中,就能很快把问答、摘要、改写、信息抽取等能力用起来。
本篇会从 .NET 开发的角度,系统讲清楚如何调用 OpenAI API。你会看到从配置密钥、创建客户端,到组织消息、设计 system prompt、维护多轮上下文,再到封装成 ASP.NET Core 服务与处理流式响应的完整过程。文章适合想把大模型能力接入控制台程序、Web API、后台任务或企业内部工具的读者。
一、调用之前先建立正确认知
1.1 什么时候应该直接调用 OpenAI API
直接调用 API 最适合那些"先把能力接上,再逐步做工程化"的场景。比如你要验证一个智能客服原型、给后台系统增加邮件改写能力、为运营同事做一个文章摘要工具,或者希望给内部文档平台增加问答入口,这些需求都可以先从最直接的 API 调用开始。它的优势是接入快、路径短、调试简单,尤其适合做第一版验证。
但你也要意识到,API 调用本身只是起点,不是完整方案。真正进入业务系统后,你还要考虑上下文长度、提示词边界、错误处理、重试、日志、安全和成本控制。如果这些工程问题开始反复出现,说明你的项目可能已经需要更高层的封装,例如引入独立服务类、缓存机制,或者进一步使用 Semantic Kernel 这类框架组织能力。
1.2 模型、消息和 token 这些概念分别代表什么
在聊天式接口里,模型不是接收一段孤立字符串,而是接收一组带角色的消息。通常情况下,system 消息用来规定助手身份、回答口吻和行为边界,user 消息表示用户输入,assistant 消息则保存历史回复。多轮对话之所以能够"记住上下文",并不是模型天生有记忆,而是你每次调用时把历史消息重新带了进去。
token 可以粗略理解成模型计费和长度限制使用的"文本单位"。输入越长、输出越长,消耗就越大。因此,AI 接口设计和普通 Web API 有一个明显不同点:你不仅要关心响应时间,还要关心上下文是否过长、提示词是否冗余,以及是否把不该发送的内容也一并塞给了模型。理解这一点之后,后面你看到的 MaxOutputTokenCount、历史裁剪和流式输出就都会更容易理解。
二、在 .NET 中完成基础配置
2.1 安装依赖与保存密钥
接入模型之前,先把项目依赖和配置方式定好,是最省时间的做法。对于 .NET 教程或小型示例,我们可以直接使用 OpenAI 官方 .NET SDK;到了正式项目里,则建议把模型名放进配置,把 API Key 放进环境变量或安全存储中,而不是写死在代码和 appsettings.json 里。
bash
dotnet add package OpenAI
json
{
"OpenAI": {
"Model": "gpt-4o-mini"
}
}
上面的配置有两个关键点。第一,NuGet 包安装完成后,你就可以直接使用 OpenAI.Chat 命名空间中的类型。第二,示例里只把模型名放进配置文件,而不把 API Key 写进去,这是因为密钥属于敏感信息,更适合通过 OPENAI_API_KEY 环境变量注入。这样做一方面更安全,另一方面也方便你在开发、测试、生产环境中切换不同密钥,而不必修改源代码。
2.2 写出一个最小可用的调用示例
在理解"消息列表"这个概念之后,最小可用代码其实非常直观。你只要创建客户端、组织消息,再把请求发给模型即可。对于初学者来说,先把这条链路打通,比一开始就追求复杂封装更重要。
csharp
using OpenAI.Chat;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? throw new InvalidOperationException("请先配置 OPENAI_API_KEY 环境变量。");
var client = new ChatClient(model: "gpt-4o-mini", apiKey: apiKey);
var messages = new List<ChatMessage>
{
new SystemChatMessage("你是一名 .NET 学习助手,请用中文、分段、尽量通俗地回答问题。"),
new UserChatMessage("请解释什么是提示词工程,以及它为什么会影响模型回答质量。")
};
var options = new ChatCompletionOptions
{
Temperature = 0.4f,
MaxOutputTokenCount = 400
};
ChatCompletion completion = await client.CompleteChatAsync(messages, options);
Console.WriteLine(completion.Content[0].Text);
这段代码里最需要你掌握的是四个点。ChatClient 负责向指定模型发送请求;messages 表示完整上下文;ChatCompletionOptions 用来控制回答风格和输出长度;CompleteChatAsync 则是真正发起调用的方法。Temperature 越低,回答通常越稳定、越收敛,越高则更发散、更有创意;MaxOutputTokenCount 则帮助你限制输出长度,避免一次回答过长。
示例里的 SystemChatMessage 不是可有可无的装饰,它实际上决定了模型回答时的角色设定。很多人觉得模型效果不稳定,往往不是模型本身有问题,而是一开始没有把身份、语气和边界说清楚。对于企业系统,这条 system prompt 往往比换一个更贵模型更重要。
三、从单轮提问走向多轮对话
3.1 多轮对话的关键在于自己维护历史
如果你只发一个问题,模型每次都会把这次调用视为全新的上下文。要想让助手记住"上一轮你说过什么",就必须把之前的用户消息和助手回复一起保留下来。也就是说,多轮对话的核心不是某个特殊 API,而是你在应用层维护历史消息的能力。
csharp
using OpenAI.Chat;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? throw new InvalidOperationException("缺少 OPENAI_API_KEY。");
var client = new ChatClient("gpt-4o-mini", apiKey);
var history = new List<ChatMessage>
{
new SystemChatMessage("你是一名架构培训助教,回答要准确并尽量结合 .NET 实践。")
};
while (true)
{
Console.Write("你:");
var input = Console.ReadLine();
if (string.Equals(input, "exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
history.Add(new UserChatMessage(input ?? string.Empty));
var completion = await client.CompleteChatAsync(history);
var answer = completion.Content[0].Text;
Console.WriteLine($"助手:{answer}");
history.Add(new AssistantChatMessage(answer));
}
这个循环示例看起来简单,但它已经把多轮对话的本质说明白了。history 是你的上下文容器,用户每说一句话,就追加一个 UserChatMessage;模型每返回一次内容,就把结果包成 AssistantChatMessage 再放回历史。下一轮调用时,模型会同时看到这些消息,因此才能理解"继续刚才的话题""把第二点再详细展开"这类追问。
当然,生产环境里不能让历史无限增长。消息越多,调用成本越高,请求越慢,也越容易触发长度限制。所以实际项目通常会做历史裁剪、摘要压缩,或者把关键事实单独保存下来,而不是无节制地把全部聊天内容原样塞回去。
3.2 system prompt 和参数设置如何影响结果
初学者最容易忽略的一点是,大模型并不是只受"问题内容"影响,它同时也受角色设定和采样参数影响。同样一句"介绍一下向量数据库",如果 system prompt 把助手设定为面向初学者的讲师,它会偏向解释;如果设定为架构顾问,它就可能更强调方案选型和性能权衡。
csharp
var messages = new List<ChatMessage>
{
new SystemChatMessage("你是一名企业架构顾问。回答时先说明概念,再说明适用场景,最后补充风险点。"),
new UserChatMessage("我们的知识库问答系统为什么要用向量检索?")
};
var options = new ChatCompletionOptions
{
Temperature = 0.2f,
MaxOutputTokenCount = 500
};
ChatCompletion completion = await client.CompleteChatAsync(messages, options);
Console.WriteLine(completion.Content[0].Text);
这里的 system prompt 明确规定了回答结构,所以模型更容易给出符合业务预期的内容。Temperature 设置为 0.2,意味着我们更希望得到稳定、可复现、偏事实性的回答,而不是发散式创作。这是一个非常典型的企业配置思路:创作类任务可以把温度设高一些,问答、归类、抽取这类任务则更适合低温度。
四、把 OpenAI 调用封装成 ASP.NET Core 服务
4.1 先把模型调用放进独立服务类
当你准备把能力接入 Web API 时,第一件事不是直接在控制器里 new 一个客户端,而是先把调用逻辑封装成服务类。这样做的原因很简单:密钥读取、system prompt、模型参数、异常处理和日志记录都属于基础设施逻辑,不应该和接口层写在一起。
csharp
using Microsoft.Extensions.Options;
using OpenAI.Chat;
public sealed class OpenAIOptions
{
public string Model { get; set; } = "gpt-4o-mini";
}
public sealed record ChatTurn(string Role, string Content);
public sealed class OpenAiChatService
{
private readonly ChatClient _client;
public OpenAiChatService(IOptions<OpenAIOptions> options)
{
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? throw new InvalidOperationException("请先配置 OPENAI_API_KEY。");
_client = new ChatClient(options.Value.Model, apiKey);
}
public async Task<string> AskAsync(
string message,
IReadOnlyList<ChatTurn>? history = null,
CancellationToken cancellationToken = default)
{
var messages = new List<ChatMessage>
{
new SystemChatMessage("你是一名企业知识助手,回答要准确、简洁,无法确认的信息要明确说明不确定。")
};
if (history is not null)
{
foreach (var turn in history)
{
messages.Add(turn.Role.Equals("assistant", StringComparison.OrdinalIgnoreCase)
? new AssistantChatMessage(turn.Content)
: new UserChatMessage(turn.Content));
}
}
messages.Add(new UserChatMessage(message));
var options = new ChatCompletionOptions
{
Temperature = 0.3f,
MaxOutputTokenCount = 500
};
ChatCompletion completion = await _client.CompleteChatAsync(messages, options, cancellationToken);
return completion.Content[0].Text;
}
}
这个服务类做了几件很关键的事情。首先,它在构造函数里只负责一次性读取配置并创建 ChatClient,避免每次请求都重复初始化。其次,AskAsync 同时接收当前消息和可选历史,这样既能处理单轮问答,也能支持多轮对话。最后,system prompt 被统一放在服务层,意味着你的 Web API、后台任务,甚至 SignalR Hub 都能复用同一套模型约束,而不是在多个地方各写一遍。
4.2 再通过 Minimal API 暴露对外接口
当服务类准备好之后,API 层就会变得很干净。它只负责接收请求、调用服务、返回结果。这种结构对后续扩展非常有利,因为你想加鉴权、日志、缓存和限流时,都不会把模型调用代码搅乱。
csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<OpenAIOptions>(builder.Configuration.GetSection("OpenAI"));
builder.Services.AddSingleton<OpenAiChatService>();
var app = builder.Build();
app.MapPost("/api/chat", async (ChatRequest request, OpenAiChatService service, CancellationToken cancellationToken) =>
{
var answer = await service.AskAsync(request.Message, request.History, cancellationToken);
return Results.Ok(new ChatResponse(answer));
});
app.Run();
public sealed record ChatRequest(string Message, List<ChatTurn>? History);
public sealed record ChatResponse(string Answer);
这个接口的重点不在于代码行数,而在于职责边界。ChatRequest 里除了当前消息,还可以附带历史记录,因此接口天生支持上下文对话。CancellationToken 则让请求在客户端断开或超时时能够被取消,这对于耗时较长的 AI 调用尤其重要。对前端来说,它只需要像调用普通业务接口一样提交 JSON,就能得到模型生成的回答。
五、流式输出、异常处理与成本控制
5.1 为什么流式输出会明显提升体验
对用户来说,AI 应用最大的体验问题之一,不是答得不对,而是等得太久。如果回答要几秒后才完整返回,前端会显得卡顿;而如果能像打字一样边生成边显示,用户通常会觉得系统更加自然。因此,只要场景允许,聊天系统通常都值得考虑流式输出。
csharp
var messages = new List<ChatMessage>
{
new UserChatMessage("请分三段解释什么是 RAG,并给出一个企业知识库问答的例子。")
};
await foreach (var update in client.CompleteChatStreamingAsync(messages))
{
foreach (var part in update.ContentUpdate)
{
Console.Write(part.Text);
}
}
这段代码使用了 CompleteChatStreamingAsync,它不会等整段内容生成完成后一次性返回,而是不断把增量文本推送出来。你可以把这些片段直接写到控制台、HTTP 响应流或者 SignalR 通道中。对于聊天机器人和智能写作助手来说,这种方式非常常见,因为它能显著改善交互感知时间。
5.2 为什么异常处理不能只写一个 try/catch
AI 接口的异常来源比普通数据库查询更复杂,既可能是网络问题、认证问题,也可能是请求过大、速率超限,或者上游服务暂时不可用。因此,项目里至少要有基础的超时、重试和日志记录机制。你不需要一开始就做得很重,但一定要让失败可观察、可恢复。
csharp
try
{
var answer = await service.AskAsync("请解释一下向量数据库和传统数据库的区别。");
Console.WriteLine(answer);
}
catch (Exception ex)
{
Console.WriteLine($"模型调用失败:{ex.Message}");
Console.WriteLine("生产环境中应记录请求上下文、响应时间和必要的错误标签,便于排查和重试。");
}
这个示例故意只保留最基础的捕获逻辑,是想强调一个原则:教程里的 try/catch 只是起点。真正上线时,你应该把失败信息写入日志系统,区分可重试错误和不可重试错误,并为前端准备友好的兜底响应。否则,一旦 AI 接口偶发失败,用户看到的就会是莫名其妙的 500 错误。
5.3 成本和安全边界为什么要提前考虑
很多 AI 原型在演示阶段效果很好,一上线却因为成本不可控或安全边界不清而被迫回滚。原因通常很现实:上下文越长,费用越高;输出越长,延迟越高;把敏感内容原样发给模型,还会引入合规风险。因此,成本和安全不是后期优化项,而是第一版就该考虑的基本约束。
实践里至少要做到几件事。第一,只发送模型真正需要的上下文,不要把整页表单或整段日志原样丢进去。第二,对用户输入做必要过滤,避免敏感字段泄露。第三,为高频问题增加缓存,例如常见帮助问答和标准化摘要。第四,给输出设置合理长度上限,别让模型在一个简单问题上写出一篇长文。把这些习惯建立起来,后面做更复杂的 AI 系统时会轻松很多。
练习题:
- 请把本文的最小调用示例改造成支持历史消息的控制台聊天程序,并说明你是如何保存上下文的。
- 假设你要做一个企业内部问答接口,为什么 system prompt 和低温度参数通常比"换更大的模型"更应该先优化?
- 如果你的接口经常超时或费用过高,请分别从上下文长度、输出长度、缓存和流式输出四个角度提出改进思路。