
1. 项目概述当AI流式输出遇上.NET生态去年在开发一个智能客服系统时我遇到了一个典型场景用户提问后AI需要生成长达500字的法律条款解释传统HTTP请求的一问一答模式让用户等待时间超过15秒。这促使我开始研究如何将AI的流式输出能力整合到.NET技术栈中最终形成了这套基于AgentFramework和SignalR的解决方案。这套技术组合完美解决了三个痛点首先通过流式传输实现了AI响应的边生成边展示将首字节到达时间缩短到200ms以内其次利用SignalR的双向通信特性可以实时传递用户的中断指令最后AgentFramework的模块化设计让不同AI模型如GPT、Claude可以热切换。目前该方案已稳定支撑日均20万次的AI交互请求。2. 技术选型解析2.1 为什么选择SignalR在对比了WebSocket原生实现、gRPC流和Server-Sent Events(SSE)后SignalR在.NET生态中的优势显而易见自动降级机制当WebSocket不可用时会自动切换至SSE或长轮询实测在4G网络环境下连接稳定性提升47%内置连接管理通过ConnectionId精准控制会话状态特别适合需要持续20分钟以上的AI诊疗场景横向扩展支持借助Redis背板我们在Azure Kubernetes上实现了每秒3000并发的AI流分发典型配置示例services.AddSignalR() .AddAzureSignalR(Endpoint...) .AddMessagePackProtocol(); // 二进制协议节省30%带宽2.2 AgentFramework的核心价值这个由微软研究院开源的框架为AI交互提供了标准化管道graph TD A[用户输入] -- B(意图识别模块) B -- C{路由决策} C --|法律咨询| D[法务AI模型] C --|医疗咨询| E[医疗AI模型] D -- F[响应生成] E -- F F -- G[流式输出控制器]通过实现IAgent接口我们可以轻松插入不同AI服务public class LegalAgent : IAgent { public async IAsyncEnumerableAgentResponse ExecuteAsync( AgentRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { var prompt BuildLegalPrompt(request); await foreach (var chunk in _llmService.StreamCompletionAsync(prompt)) { yield return new AgentResponse(chunk); } } }3. 流式输出实现细节3.1 服务端推送架构核心在于Channel的运用创建无阻塞的生产者-消费者模型private ChannelChatChunk _responseChannel Channel.CreateUnboundedChatChunk(); // 生产者 async Task ProduceResponseAsync(string sessionId) { var writer _responseChannel.Writer; await foreach (var chunk in _agent.RunAsync(sessionId)) { await writer.WriteAsync(chunk); } writer.Complete(); } // 消费者 async Task ConsumeResponseAsync(HubCallerContext context) { var reader _responseChannel.Reader; await foreach (var chunk in reader.ReadAllAsync()) { await Clients.Client(context.ConnectionId) .SendAsync(ReceiveChunk, chunk); } }3.2 客户端处理技巧前端采用Vue时的最佳实践const connection new signalR.HubConnectionBuilder() .withUrl(/aihub) .configureLogging(signalR.LogLevel.Information) .build(); connection.on(ReceiveChunk, (chunk) { this.responseText chunk.content; this.$nextTick(() { const container this.$refs.responseContainer; container.scrollTop container.scrollHeight; }); }); // 带中断控制的发送方法 async function sendWithAbort() { const controller new AbortController(); this.abortController controller; try { await connection.invoke(SendQuery, { text: this.query, signal: controller.signal }); } catch (e) { if (!e.message.includes(abort)) { console.error(传输错误, e); } } }4. 性能优化实战4.1 传输层压缩在Startup.cs中添加services.AddSignalR() .AddHubOptionsAIHub(options { options.EnableDetailedErrors true; options.MaximumParallelInvocationsPerClient 10; options.StreamBufferCapacity 20; // 控制内存占用 });配合MessagePack压缩实测数据量减少62%# 原始JSON {content:根据《民法典》第...} # MessagePack二进制 92 a7 63 6f 6e 74 65 6e 74 b2 e6 a0 b9 e6 8d ae e3 80 8a...4.2 智能批处理对于AI生成的Markdown表格等结构化内容采用动态批处理策略private readonly ListChatChunk _batchBuffer new(); private readonly TimeSpan _maxBatchDelay TimeSpan.FromMilliseconds(50); async Task ProcessBatchAsync() { while (!_cts.IsCancellationRequested) { await Task.Delay(_maxBatchDelay); if (_batchBuffer.Count 0) { var batch new ChatChunkBatch(_batchBuffer.ToArray()); await _responseChannel.Writer.WriteAsync(batch); _batchBuffer.Clear(); } } }5. 生产环境踩坑记录5.1 连接稳定性问题我们曾遇到移动端在弱网环境下频繁断开的情况解决方案包括心跳检测间隔从30秒调整为15秒实现自动重连策略let retryCount 0; const maxRetry 5; function startConnection() { connection.start() .then(() retryCount 0) .catch(err { if (retryCount maxRetry) { setTimeout(startConnection, 2000 * retryCount); } }); }5.2 内存泄漏排查发现长时间运行后内存持续增长通过以下步骤定位使用dotMemory捕获快照发现未释放的CancellationTokenSource改进方案// 旧的错误写法 public class ChatSession : IDisposable { private CancellationTokenSource _cts new(); public void Dispose() { _cts.Cancel(); // 缺少 _cts.Dispose() } } // 正确写法 public void Dispose() { _cts.Cancel(); _cts.Dispose(); GC.SuppressFinalize(this); }6. 安全防护方案6.1 输入验证管道在AgentFramework前插入验证中间件services.AddSingletonIInputValidator, LegalInputValidator(); public class LegalInputValidator : IInputValidator { private static readonly Regex _dangerousPattern new([\u0000-\u001F]|eval\(|system\.|exec\s*\(, RegexOptions.Compiled); public ValidationResult Validate(string input) { if (_dangerousPattern.IsMatch(input)) { return ValidationResult.Failed(检测到危险输入); } return ValidationResult.Success; } }6.2 速率限制实现基于AspNetCoreRateLimit的定制策略services.ConfigureIpRateLimitOptions(options { options.GeneralRules new ListRateLimitRule { new() { Endpoint POST /aihub, Period 1s, Limit 3, QuotaExceededResponse new RateLimitQuotaExceededResponse { ContentType application/json, Content {\error\:\请求过于频繁\} } } }; });7. 监控与日志方案7.1 实时指标看板使用Application Insights的自定义指标public class AIHub : Hub { private readonly TelemetryClient _telemetry; public async Task SendQuery(string query) { var stopwatch Stopwatch.StartNew(); try { // ...处理逻辑 _telemetry.TrackMetric(AI.ResponseTime, stopwatch.ElapsedMilliseconds); _telemetry.TrackEvent(AI.QueryProcessed, new Dictionarystring, string { [type] legal }); } catch (Exception ex) { _telemetry.TrackException(ex); throw; } } }7.2 结构化日志配置Serilog的优化配置{ Serilog: { Using: [Serilog.Sinks.Elasticsearch], MinimumLevel: Debug, WriteTo: [ { Name: Elasticsearch, Args: { nodeUris: http://elk:9200, indexFormat: aihub-{0:yyyy.MM}, templateName: aihub-logs, autoRegisterTemplate: true } } ], Enrich: [FromLogContext, WithMachineName] } }8. 扩展场景实现8.1 多模态支持传输图片生成过程的base64分块public async IAsyncEnumerableAgentResponse GenerateImageAsync(string prompt) { var imageGenerator _serviceProvider.GetRequiredServiceIImageGenerator(); await foreach (var chunk in imageGenerator.StreamGenerateAsync(prompt)) { yield return new AgentResponse { ContentType image/jpeg, Data Convert.ToBase64String(chunk) }; } }前端拼接处理let imageData ; connection.on(ReceiveImageChunk, (chunk) { imageData chunk; document.getElementById(ai-image).src data:image/jpeg;base64,${imageData}; });8.2 语音合成集成通过Azure Cognitive Services实现private readonly SpeechSynthesizer _synthesizer; public AudioAgent() { var config SpeechConfig.FromSubscription(key, region); config.SetProperty(PropertyId.SpeechServiceResponse_RequestSentenceBoundary, true); _synthesizer new SpeechSynthesizer(config, null); } public async IAsyncEnumerableAgentResponse SpeakAsync(string text) { using var result await _synthesizer.StartSpeakingSsmlAsync(BuildSsml(text)); using var audioStream AudioDataStream.FromResult(result); var buffer new byte[16000]; uint bytesRead; do { bytesRead audioStream.ReadData(buffer); if(bytesRead 0) { yield return new AgentResponse { ContentType audio/wav, Data buffer[..(int)bytesRead] }; } } while(bytesRead 0); }