fix: AI 流式回复过滤思考标签 + 加载历史上下文
- ChatAgentService.RunStreamingAsync 增加状态机过滤 qwen3 <think/> 标签 - RunAsync 同步方法也增加过滤 - StreamMessageEndpoint 从 Redis/DB 加载历史消息构建上下文
This commit is contained in:
parent
67b030c3c5
commit
ca7463d42b
@ -1,6 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RAG.Domain.Entities;
|
using RAG.Domain.Entities;
|
||||||
using RAG.Domain.Enums;
|
using RAG.Domain.Enums;
|
||||||
using RAG.Domain.Interfaces;
|
using RAG.Domain.Interfaces;
|
||||||
@ -40,6 +41,43 @@ public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, ICha
|
|||||||
await cache.AppendMessageAsync(conversationId,
|
await cache.AppendMessageAsync(conversationId,
|
||||||
new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct);
|
new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct);
|
||||||
|
|
||||||
|
// 加载历史消息构建上下文
|
||||||
|
var cached = await cache.GetMessagesAsync(conversationId, ct);
|
||||||
|
List<(string Role, string Content)> history;
|
||||||
|
if (cached is { Count: > 0 })
|
||||||
|
{
|
||||||
|
history = cached
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.Select(m => (m.Role, m.Content))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dbMessages = await db.ChatMessages
|
||||||
|
.Where(m => m.ConversationId == conversationId)
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
history = dbMessages.Select(m => (m.Role.ToString(), m.Content)).ToList();
|
||||||
|
|
||||||
|
// 回填 Redis 缓存
|
||||||
|
if (history.Count > 0)
|
||||||
|
{
|
||||||
|
await cache.SetMessagesAsync(conversationId,
|
||||||
|
history.Select((h, i) => new CachedChatMessage(
|
||||||
|
Guid.NewGuid(), h.Item1, h.Item2, null,
|
||||||
|
DateTime.UtcNow.AddSeconds(i))).ToList(), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var promptBuilder = new StringBuilder();
|
||||||
|
foreach (var (role, content) in history)
|
||||||
|
{
|
||||||
|
promptBuilder.AppendLine($"{role}: {content}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = promptBuilder.ToString();
|
||||||
|
|
||||||
// SSE 响应
|
// SSE 响应
|
||||||
HttpContext.Response.ContentType = "text/event-stream";
|
HttpContext.Response.ContentType = "text/event-stream";
|
||||||
HttpContext.Response.Headers.CacheControl = "no-cache";
|
HttpContext.Response.Headers.CacheControl = "no-cache";
|
||||||
@ -47,7 +85,7 @@ public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, ICha
|
|||||||
|
|
||||||
var fullReply = new StringBuilder();
|
var fullReply = new StringBuilder();
|
||||||
|
|
||||||
await foreach (var chunk in chatAgent.RunStreamingAsync(req.Content, ct))
|
await foreach (var chunk in chatAgent.RunStreamingAsync(prompt, ct))
|
||||||
{
|
{
|
||||||
fullReply.Append(chunk);
|
fullReply.Append(chunk);
|
||||||
var sseData = JsonSerializer.Serialize(new { content = chunk });
|
var sseData = JsonSerializer.Serialize(new { content = chunk });
|
||||||
@ -69,7 +107,7 @@ public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, ICha
|
|||||||
await cache.AppendMessageAsync(conversationId,
|
await cache.AppendMessageAsync(conversationId,
|
||||||
new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
|
new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
|
||||||
|
|
||||||
// 发送结束标记(含完整消息 ID)
|
// 发送结束标记
|
||||||
var doneData = JsonSerializer.Serialize(new { messageId = assistantMessage.Id });
|
var doneData = JsonSerializer.Serialize(new { messageId = assistantMessage.Id });
|
||||||
await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
|
await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
|
||||||
await HttpContext.Response.Body.FlushAsync(ct);
|
await HttpContext.Response.Body.FlushAsync(ct);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
using System.ClientModel;
|
using System.ClientModel;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@ -28,16 +29,142 @@ public class ChatAgentService : IAIChatAgent
|
|||||||
public async Task<string> RunAsync(string prompt, CancellationToken ct)
|
public async Task<string> RunAsync(string prompt, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var response = await _agent.RunAsync(prompt, null, null, ct);
|
var response = await _agent.RunAsync(prompt, null, null, ct);
|
||||||
return response.Text;
|
return FilterThinkTags(response.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<string> RunStreamingAsync(
|
public async IAsyncEnumerable<string> RunStreamingAsync(
|
||||||
string prompt, [EnumeratorCancellation] CancellationToken ct)
|
string prompt, [EnumeratorCancellation] CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// 状态机过滤 <think...>...</think\> 标签
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
var inThink = false;
|
||||||
|
|
||||||
await foreach (var update in _agent.RunStreamingAsync(prompt, null, null, ct))
|
await foreach (var update in _agent.RunStreamingAsync(prompt, null, null, ct))
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(update.Text))
|
if (string.IsNullOrEmpty(update.Text))
|
||||||
yield return update.Text;
|
continue;
|
||||||
|
|
||||||
|
buffer.Append(update.Text);
|
||||||
|
|
||||||
|
// 从 buffer 中提取可以安全输出的文本
|
||||||
|
while (buffer.Length > 0)
|
||||||
|
{
|
||||||
|
var content = buffer.ToString();
|
||||||
|
|
||||||
|
if (inThink)
|
||||||
|
{
|
||||||
|
var endIdx = content.IndexOf("</think", StringComparison.Ordinal);
|
||||||
|
if (endIdx < 0)
|
||||||
|
{
|
||||||
|
// 还在 think 块内,保留尾部以防截断
|
||||||
|
if (buffer.Length > 10)
|
||||||
|
buffer.Remove(0, buffer.Length - 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到结束标签,跳过 </think\>
|
||||||
|
var closeIdx = content.IndexOf('>', endIdx);
|
||||||
|
if (closeIdx < 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
inThink = false;
|
||||||
|
buffer.Remove(0, closeIdx + 1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var thinkIdx = content.IndexOf("<think", StringComparison.Ordinal);
|
||||||
|
if (thinkIdx < 0)
|
||||||
|
{
|
||||||
|
// 没有 think 标签,全部输出(保留尾部 7 字符防截断)
|
||||||
|
if (buffer.Length > 7)
|
||||||
|
{
|
||||||
|
var safeLen = buffer.Length - 7;
|
||||||
|
var output = buffer.ToString(0, safeLen);
|
||||||
|
buffer.Remove(0, safeLen);
|
||||||
|
if (output.Length > 0)
|
||||||
|
yield return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出 think 标签之前的内容
|
||||||
|
if (thinkIdx > 0)
|
||||||
|
yield return buffer.ToString(0, thinkIdx);
|
||||||
|
|
||||||
|
// 检查同一 chunk 内是否有 </think\>
|
||||||
|
var endIdx = content.IndexOf("</think", thinkIdx, StringComparison.Ordinal);
|
||||||
|
if (endIdx >= 0)
|
||||||
|
{
|
||||||
|
var closeIdx = content.IndexOf('>', endIdx);
|
||||||
|
if (closeIdx >= 0)
|
||||||
|
{
|
||||||
|
// 完整的 think 块在同一 chunk,直接跳过
|
||||||
|
buffer.Remove(0, closeIdx + 1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 有结束标签但不完整
|
||||||
|
inThink = true;
|
||||||
|
buffer.Clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 进入 think 块
|
||||||
|
inThink = true;
|
||||||
|
buffer.Remove(0, thinkIdx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 输出 buffer 中剩余的非 think 内容
|
||||||
|
if (!inThink && buffer.Length > 0)
|
||||||
|
yield return buffer.ToString();
|
||||||
|
|
||||||
|
buffer.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>过滤完整的文本中的 think 标签(非流式用)</summary>
|
||||||
|
private static string FilterThinkTags(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var span = text.AsSpan();
|
||||||
|
while (!span.IsEmpty)
|
||||||
|
{
|
||||||
|
var thinkStart = span.IndexOf("<think".AsSpan());
|
||||||
|
if (thinkStart < 0)
|
||||||
|
{
|
||||||
|
sb.Append(span);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 think 标签之前的内容
|
||||||
|
sb.Append(span[..thinkStart]);
|
||||||
|
|
||||||
|
// 找到 </think\>
|
||||||
|
var afterThink = span[(thinkStart + 6)..];
|
||||||
|
var gtIdx = afterThink.IndexOf('>');
|
||||||
|
if (gtIdx < 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var endTag = afterThink[(gtIdx + 1)..].IndexOf("</think".AsSpan());
|
||||||
|
if (endTag < 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var afterEnd = afterThink[(gtIdx + 1 + endTag)..];
|
||||||
|
var endGt = afterEnd.IndexOf('>');
|
||||||
|
if (endGt < 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
span = afterEnd[(endGt + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString().TrimStart('\n', '\r');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user