AIGC标识 .NET Core 与大模型交互实战(三):流式消息中断恢复

这是《.NET Core 与大模型交互》系列的第三篇。前两篇我们实现了基础流式对话和工具调用;本篇将解决一个生产环境常见问题:流式传输中断后,如何恢复并继续生成


系列大纲

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

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

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

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


一、流式响应的“阿喀琉斯之踵”

前两篇我们实现了 SSE 流式输出,用户可以看到大模型“逐字生成”的效果。但生产环境中,流式连接非常脆弱:

中断场景 原因
用户网络波动 移动网络切换、WiFi 信号差
前端页面刷新/关闭 用户误操作或浏览器崩溃
负载均衡超时 Nginx/网关对长连接有时间限制
模型服务限流 429/529 错误导致连接断开
后端重启 部署、扩缩容

一旦连接中断,前端拿到的只是半截回答。用户刷新页面后,只能重新发起请求,大模型重新生成一遍——既浪费 token,又影响体验

理想方案是:

用户: 请写一篇 5000 字的文章...
  ↓
大模型生成到 3000 字时,网络中断
  ↓
用户重新打开页面,点击“继续”
  ↓
后端: 读取上次保存的 3000 字
  ↓
后端: 发送“请从断点处继续”给大模型
  ↓
大模型: 从第 3000 字之后继续生成...
  ↓
前端: 收到完整 5000 字

这就是本篇要实现的 流式消息中断恢复


二、核心设计思路

中断恢复的关键是三个问题:

  1. 何时保存?
    在流式读取过程中,每拿到一个 chunk 就累积到内存;当连接中断时,把已累积的内容持久化。

  2. 保存什么?
    保存完整的对话上下文,包括 system prompt、用户消息、assistant 已生成内容、工具调用记录等。

  3. 如何恢复?
    读取历史消息,把最后一条 assistant 消息作为“断点”,构造一个“请继续”的提示,重新发起流式请求。


三、项目新增文件

在现有项目基础上,我们新增:

Services/IChatService.cs       -> 聊天服务接口
Services/ChatService.cs        -> 聊天服务实现(核心)
Services/IChatSession.cs       -> 会话管理接口
Services/ChatSession.cs        -> 会话管理实现
Services/IllmService.cs        -> LLM 服务接口
Models/TempSession.cs          -> 临时会话消息模型
Messages/                      -> 消息持久化目录

四、定义会话模型

我们需要一个轻量级的模型来存储每条消息:

// Models/TempSession.cs
public class TempSession
{
    public string Role { get; set; }        // system / user / assistant / tool
    public string Content { get; set; }     // 消息内容
    public string ToolCallId { get; set; }  // 工具调用 ID(如果有)
}

这个模型设计得简单,只保存必要字段,方便序列化到文件或数据库。


五、实现会话管理

IChatSession 负责在内存中累积工具调用消息:

// Services/IChatSession.cs
public interface IChatSession
{
    void AddMessage(string role, string content, string toolCallId = "");
    void AddMessages(IEnumerable<TempSession> messages);
    List<TempSession> GetToolMessages();
}
// Services/ChatSession.cs
public class ChatSession : IChatSession
{
    private List<TempSession> _messages = new List<TempSession>();

    public void AddMessage(string role, string content, string toolCallId = "")
    {
        _messages.Add(new TempSession
        {
            Role = role,
            Content = content,
            ToolCallId = toolCallId
        });
    }

    public void AddMessages(IEnumerable<TempSession> messages)
    {
        _messages.AddRange(messages);
    }

    public List<TempSession> GetToolMessages() => _messages;
}

当前实现是内存级的,生命周期与请求绑定。生产环境可以替换为 Redis 或数据库实现。


六、定义 LLM 服务接口

我们把流式调用抽象出来,方便后续替换实现:

// Services/IllmService.cs
public interface IllmService
{
    IAsyncEnumerable<string> ChatStreamAsync(
        List<LlmMessageRequest> messages, 
        CancellationToken cancellationToken = default);
}

七、核心实现:ChatService

这是本篇最核心的部分。ChatService 实现了两个关键方法:

  • SendMessageStreamAsync:发送消息并保存流式内容
  • ContinueStreamAsync:从中断点继续对话

7.1 发送消息并保存

// Services/ChatService.cs
public async IAsyncEnumerable<string> SendMessageStreamAsync(
    string userMessage, 
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    List<LlmMessageRequest> messages = new List<LlmMessageRequest> {
        new LlmMessageRequest
        {
            Role = "system",
            Content = "你是一个生活小助手,你可以帮我处理一些生活中的问题。"
        },
        new LlmMessageRequest
        {
            Role = "user",
            Content = userMessage
        }
    };

    StringBuilder fullContent = new StringBuilder();

    // 读流,保存中断内容
    // 注意 yield 在 try-catch-finally 中的使用
    try
    {
        await foreach (var item in _llmService.ChatStreamAsync(messages, cancellationToken))
        {
            if (!string.IsNullOrWhiteSpace(item))
            {
                yield return item;
                fullContent.Append(item);   // 保存内容
            }
        }
    }
    finally
    {
        // 保存中断内容
        // 异常由外层 catch 捕获,finally 执行后本函数结束
        if (cancellationToken.IsCancellationRequested)
        {
            // 这里可以保存中间内容到数据库,或者做其他处理
            await SaveMessageAsync(true, fullContent.ToString());
        }
    }

    // 正常结束时,保存完整内容
    await SaveMessageAsync(false, fullContent.ToString());

    yield return "other message";
}

7.2 关键设计点:try-finally 与 yield

这段代码有一个精妙之处:yield returntry-finally 的配合

当消费者(Controller)在 await foreach 过程中取消时:

  1. cancellationToken 触发取消;
  2. ReadLineAsync 抛出 OperationCanceledException
  3. finally 块执行,保存已累积的内容;
  4. 方法结束,yield return 不再执行。

如果没有 finally,取消时已生成的内容就会丢失。

7.3 继续对话

public async IAsyncEnumerable<string> ContinueStreamAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    // 获取历史消息
    string path = $"Messages/{DateTime.Now:yyyy-MM-dd}.txt";
    List<LlmMessageRequest> llmMessages = new List<LlmMessageRequest>();
    
    if (!File.Exists(path)) yield break;
    
    var messageHistory = await File.ReadAllLinesAsync(path);
    if (messageHistory == null || messageHistory.Length == 0) yield break;
    
    // 检查是否有中断标记
    if (!messageHistory.Any(f => f.Contains("[Interrupted]"))) yield break;

    // 反序列化历史消息
    foreach (var message in messageHistory)
    {
        if (string.IsNullOrWhiteSpace(message)) continue;

        try
        {
            var msg = System.Text.Json.JsonSerializer.Deserialize<TempSession>(message);
            if (msg != null)
            {
                llmMessages.Add(new LlmMessageRequest
                {
                    Role = msg.Role,
                    Content = msg.Content,
                    ToolCallId = msg.ToolCallId
                });
            }
        }
        catch
        {
            // 忽略解析失败的行
        }
    }

    // 这里默认模型在推理时中断了,实际上还应该判断其它场景,如:工具调用时中断,工具调用后中断,429,529等情况
    var interruptedMessage = llmMessages.LastOrDefault(m => m.Role == "assistant");
    if (interruptedMessage == null) yield break;

    // 构造继续提示词
    string continuePrompt = $"**请继续完成上次未完成的回复。上次回复内容到此中断:「{interruptedMessage.Content}」\n\n请从断点处自然地继续,不要重复已有的内容。**";

    // 构建消息列表
    List<LlmMessageRequest> messages = new List<LlmMessageRequest> {
        new LlmMessageRequest
        {
            Role = "system",
            Content = $"你是一个生活小助手,你可以帮我处理一些生活中的问题。\n\n{continuePrompt}"
        }
    };

    // 添加历史消息
    messages.AddRange(llmMessages);

    // 继续流式输出
    StringBuilder fullContent = new StringBuilder();

    try
    {
        await foreach (var item in _llmService.ChatStreamAsync(messages, cancellationToken))
        {
            if (!string.IsNullOrWhiteSpace(item))
            {
                yield return item;
                fullContent.Append(item);
            }
        }
    }
    finally
    {
        if (cancellationToken.IsCancellationRequested)
        {
            await SaveMessageAsync(true, fullContent.ToString());
        }
    }

    await SaveMessageAsync(false, fullContent.ToString());

    yield return "other message";
}

7.4 保存消息

private async Task SaveMessageAsync(bool interrupted, string reasonContent)
{
    StringBuilder messageBuilder = new StringBuilder();

    if (interrupted)
    {
        messageBuilder.AppendLine("[Interrupted]"); // 追加中断标记
    }

    // 模型推理的消息
    var reasonMessage = new TempSession
    {
        Role = "assistant",
        Content = reasonContent
    };

    var msgPath = $"Messages/{DateTime.Now:yyyy-MM-dd}.txt";
    string oldContent = string.Empty;
    bool fileExists = File.Exists(msgPath);
    
    if (fileExists)
    {
        oldContent = await File.ReadAllTextAsync(msgPath);
        oldContent = oldContent.Replace("[Interrupted]", ""); // 移除之前的中断标记
    }

    var jsonOption = new System.Text.Json.JsonSerializerOptions 
    { 
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping 
    };
    
    messageBuilder.AppendLine(System.Text.Json.JsonSerializer.Serialize(reasonMessage, jsonOption));

    // 工具调用结果
    var toolCallMessages = _chatSession.GetToolMessages();
    if (toolCallMessages.Any())
    {
        toolCallMessages.ForEach(t => 
            messageBuilder.AppendLine(System.Text.Json.JsonSerializer.Serialize(t, jsonOption)));
    }

    await File.WriteAllTextAsync(msgPath, oldContent + messageBuilder.ToString(), Encoding.UTF8);
}

八、消息文件格式

持久化后的消息文件(Messages/2026-08-07.txt)格式如下:

{"Role":"user","Content":"请写一篇 5000 字的文章...","ToolCallId":""}
[Interrupted]
{"Role":"assistant","Content":"好的,我来为你写一篇关于...(已生成 3000 字)","ToolCallId":""}

当用户点击“继续”时:

  1. 读取文件,检测到 [Interrupted] 标记;
  2. 反序列化所有消息;
  3. 找到最后一条 assistant 消息;
  4. 构造 continue prompt;
  5. 重新发起流式请求。

九、Controller 层对接

BigModelController 中新增两个端点:

// 发送消息(支持中断保存)
[HttpPost, Route("sendmsg")]
public async IAsyncEnumerable<string> SendMsg(
    [FromBody] SendMsgInput input, 
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    await foreach (var item in _chatService.SendMessageStreamAsync(input.Message, cancellationToken))
    {
        yield return item;
    }
}

// 继续中断的对话
[HttpPost, Route("continue")]
public async IAsyncEnumerable<string> ContinueMsg(
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    await foreach (var item in _chatService.ContinueStreamAsync(cancellationToken))
    {
        yield return item;
    }
}

前端逻辑:

// 发送消息
async function sendMessage(message) {
  const response = await fetch('/api/BigModel/sendmsg', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message }),
    signal: controller.signal
  });
  
  // 读取 SSE 流...
}

// 继续对话
async function continueMessage() {
  const response = await fetch('/api/BigModel/continue', {
    method: 'POST',
    signal: controller.signal
  });
  
  // 读取 SSE 流...
}

十、关键流程解析

10.1 中断检测流程

用户发起请求
    ↓
ChatService.SendMessageStreamAsync
    ↓
_llmService.ChatStreamAsync 开始流式生成
    ↓
每生成一个 token -> fullContent.Append(item)
    ↓
用户取消/网络中断
    ↓
OperationCanceledException 抛出
    ↓
finally 块执行
    ↓
cancellationToken.IsCancellationRequested == true
    ↓
SaveMessageAsync(true, partialContent)
    ↓
写入 [Interrupted] 标记 + 部分内容

10.2 恢复流程

用户点击“继续”
    ↓
ChatService.ContinueStreamAsync
    ↓
读取 Messages/2026-08-07.txt
    ↓
检测到 [Interrupted] 标记
    ↓
反序列化所有消息
    ↓
找到最后一条 assistant 消息
    ↓
构造 continuePrompt
    ↓
重新调用 _llmService.ChatStreamAsync
    ↓
大模型从断点处继续生成
    ↓
前端收到续写内容

10.3 continuePrompt 的设计

string continuePrompt = $"** 请继续完成上次未完成的回复。上次回复内容到此中断:「{interruptedMessage.Content}」\n\n请从断点处自然地继续,不要重复已有的内容。**";

10.4 执行效果图

  • 中断

1

  • 中断后存储内容

4

  • 恢复

2

  • 最终文件存储内容

3

这个提示词做了三件事:

  1. 明确告知:告诉模型需要继续上次的回复;
  2. 提供上下文:把中断时的内容喂给模型;
  3. 避免重复:强调“不要重复已有的内容”。

十一、生产环境优化建议

11.1 持久化方案

当前实现使用文件系统存储,只作为演示。生产环境建议使用redis或者数据库,且流式消息建议使用channel,实时入库

11.2 中断场景判断

当前代码只判断了 cancellationToken.IsCancellationRequested,实际还应处理:

  • 429/529 错误:模型服务限流,需要指数退避重试;
  • 工具调用中断:执行工具时中断,恢复时需要重新执行工具;
  • 部分完成:工具调用完成,但最终回答未完成。

11.3 去重与幂等

如果用户多次点击“继续”,需要防止重复恢复:

// 恢复后立即移除 [Interrupted] 标记
await SaveMessageAsync(false, fullContent.ToString());

11.4 前端状态同步

前端需要知道:

  • 当前消息是否已中断(显示“继续”按钮);
  • 恢复进度(显示“正在恢复...”);
  • 恢复失败的处理(显示“重新开始”)。

十二、完整流程回顾

第一次请求:
用户: 请写一篇 5000 字的文章...
  ↓
ChatService.SendMessageStreamAsync
  ↓
_llmService.ChatStreamAsync 开始生成
  ↓
生成 3000 字后,网络中断
  ↓
finally 块执行
  ↓
SaveMessageAsync(true, partialContent)
  ↓
Messages/2026-08-07.txt 写入 [Interrupted] + 部分内容

用户重新打开页面:
  ↓
前端检测到 [Interrupted] 标记
  ↓
显示“继续”按钮

用户点击“继续”:
  ↓
ChatService.ContinueStreamAsync
  ↓
读取历史消息
  ↓
构造 continuePrompt
  ↓
重新调用 _llmService.ChatStreamAsync
  ↓
大模型从断点处继续生成剩余 2000 字
  ↓
SaveMessageAsync(false, fullContent)
  ↓
移除 [Interrupted] 标记
  ↓
前端收到完整 5000 字

十三、常见问题

13.1 如果大模型重新生成了前面内容怎么办?

通过 continuePrompt 明确告知“不要重复已有的内容”。大多数现代模型都能很好地遵循这个指令。如果担心,可以在 prompt 中加更强的约束:

string continuePrompt = @"** 严格指令:你只需要输出从断点处开始的新内容,绝对不要重复、引用或总结之前的内容。直接开始写,不要加任何前缀。**";

13.2 工具调用中断怎么办?

当前实现没有处理工具调用中断场景。如果中断发生在工具调用阶段,恢复时需要:

  1. 检测到中断时正在执行工具;
  2. 重新执行工具;
  3. 把工具结果加入上下文;
  4. 继续生成。

13.3 可以支持多轮对话恢复吗?

完全可以。ContinueStreamAsync 读取的是完整的历史消息列表,包括多轮 user/assistant 对话。只要历史文件存在,就可以从任意断点恢复。

13.4 如何限制历史消息长度?

大模型有上下文窗口限制。如果历史消息太长,需要做截断或摘要:

// 只保留最近 N 条消息
llmMessages = llmMessages.TakeLast(20).ToList();

// 或者对早期消息做摘要
var summary = await SummarizeMessages(llmMessages.Take(50));
llmMessages.Insert(0, new LlmMessageRequest { Role = "system", Content = summary });

十四、总结

本文我们实现了流式消息中断恢复的完整方案:

  • try-finally 捕获流式读取过程中的取消事件;
  • [Interrupted] 标记标识中断状态;
  • 用文件系统持久化对话历史;
  • ContinueStreamAsync 从断点处恢复对话;
  • 通过 continuePrompt 让大模型“无缝续写”。

这套方案的核心思想是:把流式生成看作一个可保存、可恢复的状态机。无论前端如何断连,后端都能找到断点,让大模型继续工作。


十五、系列总结

三篇文章,我们从一个最小可运行的 .NET Web API 项目出发,逐步构建了一个完整的大模型交互系统:

篇章 核心能力 关键技术
第一篇 基础对话与流式响应 SSE、IAsyncEnumerable、BodyWriter
第二篇 工具调用 Function Calling、ToolRegistry、工具执行闭环
第三篇 流式消息中断恢复 try-finally、状态持久化、断点续写

这个系统已经具备了生产环境的基础骨架。你可以在此基础上继续扩展:

  • 接入 RAG(检索增强生成);
  • 添加用户认证与权限控制;
  • 用 Redis 替换文件存储;
  • 添加流式响应的心跳机制,防止连接被网关超时关闭。

posted @ 2026-08-07 16:48  scotly  阅读(2)  评论(0)    收藏  举报