.NET Core 与大模型交互实战(一):基础对话与流式响应
本文将以一个最小可运行的 Web API 项目为例,演示如何让 .NET 后端与大模型进行 基础对话 和 SSE 流式响应。
这是《.NET Core 与大模型交互》系列的第一篇。本系列将带你从零开始,在 .NET 项目中逐步接入大模型能力:从最基础的对话与流式输出,到工具调用,再到流式消息中断恢复。
大纲
本系列共三篇,围绕同一个 .NET Web API 项目逐步演进:
-
基本对话与流式响应
搭建 .NET 后端与大模型的最小交互链路,实现 SSE 流式输出,让前端可以“边生成边显示”。 -
增加一个工具
在流式对话中引入工具调用能力,让大模型在需要时调用本地服务(如天气查询),再把结果回传给模型继续生成。 -
流式消息中断恢复
解决流式传输中的断连、重连问题,实现消息中断后的状态恢复与续写,提升生产环境可用性。
一、项目结构概览
先看一下核心文件分工:
Controllers/BigModelController.cs -> 对外暴露的 SSE 接口
Clients/ILlmApi.cs -> 封装大模型 HTTP 调用
Configs/LLMConfig.cs -> 模型地址、Key、模型名配置
Models/LlmRequest.cs -> 请求模型
Models/OpenAiChatStreamResponse.cs -> 流式响应模型
Program.cs -> 服务注册与启动入口
二、配置层
配置大模型基本信息。我们使用 IOptions<T>:
// Configs/LLMConfig.cs
namespace WebApplication.Configs
{
public class LLMConfig
{
public string ApiKey { get; set; }
public string Model { get; set; }
public string BaseUrl { get; set; }
}
}
在 Program.cs 中绑定配置:
builder.Services.Configure<LLMConfig>(builder.Configuration.GetSection("llm"));
对应的 appsettings.json:
{
"llm": {
"BaseUrl": "https://your-llm-provider.com",
"ApiKey": "your-api-key",
"Model": "your-model-name"
}
}
这样切换模型提供商时,只需要改配置,不需要动业务代码。
三、客户端抽象:ILlmApi
我们使用WebApiClientCore来调用大模型接口,你也可以使用原生httpclient或者其它框架:
// Clients/ILlmApi.cs
using WebApiClientCore.Attributes;
using WebApplication.Models;
namespace WebApplication.Clients
{
public interface ILlmApi
{
[AuthHeader]
[RawReturn]
[WebApiClientCore.Attributes.HttpPost("/v1/chat/completions")]
Task<Stream> SendMessageStreamAsync([JsonContent] LlmRequest request, CancellationToken cancellationToken);
}
}
这个库帮我们做了几件重要的事:
[HttpPost]声明请求路径;[JsonContent]自动序列化请求体;[RawReturn]+Task<Stream>让我们拿到原始响应流,而不是等整个响应下载完;[AuthHeader]自动注入鉴权头(通常就是Authorization: Bearer <ApiKey>)。
在 Program.cs 中注册:
builder.Services.AddHttpApi<ILlmApi>((option, provider) =>
{
var llmConfig = provider.GetRequiredService<IOptions<LLMConfig>>().Value;
option.HttpHost = new Uri(llmConfig.BaseUrl);
});
这样 ILlmApi 就可以通过依赖注入拿到,而且基地址从配置读取。
四、请求模型:LlmRequest
大模型的对话接口通常接受一个标准的 JSON 结构:
// Models/LlmRequest.cs
public class LlmRequest
{
[JsonPropertyName("model")]
public string Model { get; set; }
[JsonPropertyName("stream")]
public bool Stream { get; set; }
[JsonPropertyName("messages")]
public List<LlmMessageRequest> Messages { get; set; } = [];
}
public class LlmMessageRequest
{
[JsonPropertyName("role")]
public string Role { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
}
关键点:
stream: true告诉模型我们要流式返回;messages数组支持多轮对话上下文;
五、核心:流式响应端点
这是本文最核心的部分。我们通过 Server-Sent Events (SSE) 把大模型的流式输出实时推送给前端。
5.1 接口定义
// Controllers/BigModelController.cs
[HttpPost, Route("openai-sendmsg-stream")]
public async Task SendMsg([FromBody] SendMsgInput input, CancellationToken cancellationToken)
{
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["X-Accel-Buffering"] = "no";
Response.Headers["Connection"] = "keep-alive";
var jsonOptions = new System.Text.Json.JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
await foreach (var item in ChatStreamAsync(input.Message, cancellationToken))
{
await Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes($"data:{item}\r\n\r\n"));
await Response.BodyWriter.FlushAsync();
}
await Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes($"data: [DONE]\r\n\r\n"));
await Response.BodyWriter.FlushAsync();
}
5.2 为什么用这些响应头?
| 响应头 | 作用 |
|---|---|
Content-Type: text/event-stream |
告诉浏览器/客户端这是 SSE 流 |
Cache-Control: no-cache |
禁止中间层缓存,保证实时性 |
X-Accel-Buffering: no |
禁用 Nginx 缓冲(如果部署在 Nginx 后) |
Connection: keep-alive |
保持长连接 |
5.3 SSE 格式
每一行数据遵循 SSE 规范:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"是"}}]}
data: [DONE]
注意 最后发送 data: [DONE] 表示流结束。
六、流式读取与解析:ChatStreamAsync
这是整个方案的“发动机”。我们使用 IAsyncEnumerable<string> 把原始 HTTP 流转换成 C# 的异步流:
private async IAsyncEnumerable<string> ChatStreamAsync(string userMessage, [EnumeratorCancellation] CancellationToken cancellation = default)
{
var request = new LlmRequest
{
Model = _config.Model,
Stream = true,
Messages = new List<LlmMessageRequest>
{
new LlmMessageRequest { Role = "system", Content = "你是一个脱口秀大师.你擅长给人们带来欢乐" },
new LlmMessageRequest { Role = "user", Content = userMessage }
}
};
using var stream = await _lmApi.SendMessageStreamAsync(request, cancellation);
using var reader = new System.IO.StreamReader(stream);
while (!cancellation.IsCancellationRequested)
{
string? line;
try
{
line = await reader.ReadLineAsync(cancellation);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "读取 LLM 流式响应异常");
break;
}
if (line == null) break;
if (string.IsNullOrWhiteSpace(line)) continue;
if (!line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
OpenAiChatStreamResponse? chunk = null;
try
{
chunk = System.Text.Json.JsonSerializer.Deserialize<OpenAiChatStreamResponse>(data);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "解析流式 chunk 失败: {Data}", data);
continue;
}
var choice = chunk?.Choices?.FirstOrDefault();
var delta = choice?.Delta;
if (delta != null)
{
var thinkContent = string.Empty;
if (!string.IsNullOrWhiteSpace(delta.Content))
{
thinkContent = delta.Content;
}
else if (!string.IsNullOrWhiteSpace(delta.Reasoning))
{
thinkContent = delta.Reasoning;
}
else if (!string.IsNullOrWhiteSpace(delta.ReasoningContent))
{
thinkContent = delta.ReasoningContent;
}
if (!string.IsNullOrWhiteSpace(thinkContent))
{
Console.Write(thinkContent);
assistantContents.Add(thinkContent);
yield return thinkContent;
}
}
}
}
6.1 关键设计点
1. 逐行读取,而非一次性读取
大模型流式返回时,数据是分块(chunk)到达的。我们使用 StreamReader.ReadLineAsync 逐行读取,每行就是一个 SSE 事件。这样内存占用极低,哪怕生成万字长文也不会爆内存。
2. 容错性
- 空行直接跳过;
- 非
data:开头的行忽略; - JSON 解析失败只打日志,不中断整个流;
- 网络异常捕获后
break,保证连接优雅关闭。
3. 兼容推理模型
代码中同时检查了三个字段:
delta.Content // 普通内容(GPT、通义千问等)
delta.Reasoning // 推理过程(部分厂商格式)
delta.ReasoningContent // 推理内容(DeepSeek R1 等)
这意味着这套代码可以兼容普通对话模型和推理模型,不需要因为换模型而重写解析逻辑。
4. IAsyncEnumerable 的威力
yield return 让方法变成异步流。Controller 里用 await foreach 消费,每拿到一个 chunk 就立刻推送给前端,实现真正的“边生成边输出”。
七、流式响应模型
// Models/OpenAiChatStreamResponse.cs
public class OpenAiChatStreamResponse
{
[JsonPropertyName("choices")]
public List<OpenAiChoice> Choices { get; set; } = [];
}
public class OpenAiChoice
{
[JsonPropertyName("delta")]
public OpenAiMessageContent? Delta { get; set; }
[JsonPropertyName("finish_reason")]
public string? FinishReason { get; set; }
}
public class OpenAiMessageContent
{
[JsonPropertyName("role")]
public string? Role { get; set; }
[JsonPropertyName("content")]
public string? Content { get; set; }
[JsonPropertyName("reasoning")]
public string? Reasoning { get; set; }
[JsonPropertyName("reasoning_content")]
public string? ReasoningContent { get; set; }
}
这个模型映射的是 OpenAI 兼容格式的流式 chunk。大多数国内大模型厂商(通义千问、DeepSeek、智谱等)都兼容这个格式,或者只需要微调这个模型就能适配。
八、常见问题与调优
8.1 为什么不用 Response.WriteAsync 而用 BodyWriter?
BodyWriter 是 PipeWriter,它支持非阻塞写入,在高并发下性能更好。对于 SSE 这种高频小包场景,BodyWriter + FlushAsync 是更现代的做法。
8.2 如何取消请求?
Controller 接收了 CancellationToken,并一路传递到 ReadLineAsync 和 SendMessageStreamAsync。前端关闭连接或调用 AbortController 时,后端会自动停止读取和生成。
8.3 日志打太多怎么办?
代码中使用了 LogDebug 记录解析失败的 chunk,LogWarning 记录网络异常。生产环境建议:
- 只保留异常日志;
- 对解析失败的 chunk 做采样统计,监控模型返回格式变化。
8.4 超时怎么处理?
可以在 ILlmApi 或 HttpClient 层面设置超时。对于流式请求,建议超时时间设长一些(如 120 秒),因为模型生成需要时间。
九、完整流程回顾
前端 POST /api/BigModel/openai-sendmsg-stream
↓
BigModelController.SendMsg 设置 SSE 响应头
↓
ChatStreamAsync 构建 LlmRequest
↓
ILlmApi.SendMessageStreamAsync 调用大模型 /v1/chat/completions
↓
逐行读取 SSE 流,解析 JSON chunk
↓
yield return 文本片段
↓
Controller 写入 Response.BodyWriter
↓
前端实时收到 data: {...}
十、运行结果预览

十一、总结
本文我们实现了一个最小但完整的 .NET + 大模型流式对话方案:
- 用
IOptions<T>管理模型配置; - 用
WebApiClientCore封装 HTTP 调用; - 用
IAsyncEnumerable<string>+yield return实现异步流; - 用 SSE 协议把 token 实时推送给前端。
这套骨架非常稳固,后续加工具调用、加中断恢复、加多轮对话记忆,都可以在这个基础上扩展。
十二、预告:下一篇
第二篇:给大模型增加一个工具
我们将实现:
- 定义工具描述(Function Calling / Tool Call);
- 在流式响应中检测模型是否要求调用工具;
- 执行本地工具(如天气查询);
- 把工具结果回传给模型,继续生成最终回答。

浙公网安备 33010602011771号