.NET Core 与大模型交互实战(三):流式消息中断恢复
这是《.NET Core 与大模型交互》系列的第三篇。前两篇我们实现了基础流式对话和工具调用;本篇将解决一个生产环境常见问题:流式传输中断后,如何恢复并继续生成。
系列大纲
本系列共三篇,围绕同一个 .NET Web API 项目逐步演进:
-
基本对话与流式响应
搭建 .NET 后端与大模型的最小交互链路,实现 SSE 流式输出,让前端可以“边生成边显示”。 -
增加一个工具
在流式对话中引入工具调用能力,让大模型在需要时调用本地服务(如天气查询),再把结果回传给模型继续生成。 -
流式消息中断恢复
解决流式传输中的断连、重连问题,实现消息中断后的状态恢复与续写,提升生产环境可用性。
一、流式响应的“阿喀琉斯之踵”
前两篇我们实现了 SSE 流式输出,用户可以看到大模型“逐字生成”的效果。但生产环境中,流式连接非常脆弱:
| 中断场景 | 原因 |
|---|---|
| 用户网络波动 | 移动网络切换、WiFi 信号差 |
| 前端页面刷新/关闭 | 用户误操作或浏览器崩溃 |
| 负载均衡超时 | Nginx/网关对长连接有时间限制 |
| 模型服务限流 | 429/529 错误导致连接断开 |
| 后端重启 | 部署、扩缩容 |
一旦连接中断,前端拿到的只是半截回答。用户刷新页面后,只能重新发起请求,大模型重新生成一遍——既浪费 token,又影响体验。
理想方案是:
用户: 请写一篇 5000 字的文章...
↓
大模型生成到 3000 字时,网络中断
↓
用户重新打开页面,点击“继续”
↓
后端: 读取上次保存的 3000 字
↓
后端: 发送“请从断点处继续”给大模型
↓
大模型: 从第 3000 字之后继续生成...
↓
前端: 收到完整 5000 字
这就是本篇要实现的 流式消息中断恢复。
二、核心设计思路
中断恢复的关键是三个问题:
-
何时保存?
在流式读取过程中,每拿到一个 chunk 就累积到内存;当连接中断时,把已累积的内容持久化。 -
保存什么?
保存完整的对话上下文,包括 system prompt、用户消息、assistant 已生成内容、工具调用记录等。 -
如何恢复?
读取历史消息,把最后一条 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 return 与 try-finally 的配合。
当消费者(Controller)在 await foreach 过程中取消时:
cancellationToken触发取消;ReadLineAsync抛出OperationCanceledException;finally块执行,保存已累积的内容;- 方法结束,
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":""}
当用户点击“继续”时:
- 读取文件,检测到
[Interrupted]标记; - 反序列化所有消息;
- 找到最后一条 assistant 消息;
- 构造 continue prompt;
- 重新发起流式请求。
九、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 执行效果图
- 中断

- 中断后存储内容

- 恢复

- 最终文件存储内容

这个提示词做了三件事:
- 明确告知:告诉模型需要继续上次的回复;
- 提供上下文:把中断时的内容喂给模型;
- 避免重复:强调“不要重复已有的内容”。
十一、生产环境优化建议
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 工具调用中断怎么办?
当前实现没有处理工具调用中断场景。如果中断发生在工具调用阶段,恢复时需要:
- 检测到中断时正在执行工具;
- 重新执行工具;
- 把工具结果加入上下文;
- 继续生成。
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 替换文件存储;
- 添加流式响应的心跳机制,防止连接被网关超时关闭。

浙公网安备 33010602011771号