AIGC标识 .NET Core 与大模型交互实战(一):基础对话与流式响应

本文将以一个最小可运行的 Web API 项目为例,演示如何让 .NET 后端与大模型进行 基础对话SSE 流式响应
这是《.NET Core 与大模型交互》系列的第一篇。本系列将带你从零开始,在 .NET 项目中逐步接入大模型能力:从最基础的对话与流式输出,到工具调用,再到流式消息中断恢复。


大纲

本系列共三篇,围绕同一个 .NET Web API 项目逐步演进:

  1. 基本对话与流式响应
    搭建 .NET 后端与大模型的最小交互链路,实现 SSE 流式输出,让前端可以“边生成边显示”。

  2. 增加一个工具
    在流式对话中引入工具调用能力,让大模型在需要时调用本地服务(如天气查询),再把结果回传给模型继续生成。

  3. 流式消息中断恢复
    解决流式传输中的断连、重连问题,实现消息中断后的状态恢复与续写,提升生产环境可用性。


一、项目结构概览

先看一下核心文件分工:

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

BodyWriterPipeWriter,它支持非阻塞写入,在高并发下性能更好。对于 SSE 这种高频小包场景,BodyWriter + FlushAsync 是更现代的做法。

8.2 如何取消请求?

Controller 接收了 CancellationToken,并一路传递到 ReadLineAsyncSendMessageStreamAsync。前端关闭连接或调用 AbortController 时,后端会自动停止读取和生成。

8.3 日志打太多怎么办?

代码中使用了 LogDebug 记录解析失败的 chunk,LogWarning 记录网络异常。生产环境建议:

  • 只保留异常日志;
  • 对解析失败的 chunk 做采样统计,监控模型返回格式变化。

8.4 超时怎么处理?

可以在 ILlmApiHttpClient 层面设置超时。对于流式请求,建议超时时间设长一些(如 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);
  • 在流式响应中检测模型是否要求调用工具;
  • 执行本地工具(如天气查询);
  • 把工具结果回传给模型,继续生成最终回答。

posted @ 2026-08-06 18:03  scotly  阅读(3)  评论(0)    收藏  举报