【Bug已解决】How to stream Claude response with SignalR and Claudia in .Net 6 解决方案

📅 2026/8/21 16:11:18
【Bug已解决】How to stream Claude response with SignalR and Claudia in .Net 6 解决方案
【Bug已解决】How to stream Claude response with SignalR and Claudia in .Net 6 解决方案一、现象长什么样你在 .NET 6 用Claudia配合SignalR把 Claude 的回答流式推到浏览器遇到了和一次就到相反或相关的另一类问题配置了流式但前端收到的仍然是整段后才出现不是逐 token多轮对话时流式只在第一轮生效后续轮次变同步中途断开重连后流没有恢复前端卡在思考中你怀疑是 Claudia 的流式 API 没接对或 SignalR 的 hub 方法签名限制了推送控制台能看到 Claudia 在 yield 片段但前端没收到对应on事件。一句话即便用了流式库如果 SignalR 的 hub 没有为每个连接维持推送通道、或流式迭代在异常/重连时被中断前端仍看不到稳定流式——问题在流式管线在传输层被截断了。二、背景回顾三个角色详见 938Claudia 负责产出 token 片段流式SignalR 负责实时推送.NET 6 是宿主。938 讲了hub 要SendAsync逐块推而非 return 整体。本篇聚焦流式管线的稳定性SignalR 的Clients.Caller只在当前连接上下文有效一旦连接断开未发出的SendAsync会失败流式迭代await foreach若在try外抛异常hub 方法异常退出剩余片段丢失多轮对话时每次都要重新建立 Claudia 调用若把流式状态挂成单例没清会串台前端connection.on的事件名必须与SendAsync的第一个参数严格一致否则收不到。三、根因根因是流式管线在传输层不稳定异常未兜底、连接上下文丢失、事件名不匹配// 问题流式迭代抛异常时 hub 直接崩前端只看到半截 public async Task Stream(string prompt) { await foreach (var c in _claudia.StreamAsync(prompt)) await Clients.Caller.SendAsync(Token, c); // 若中途连接断 - 抛异常 - 剩余丢失 }修复方向给流式迭代加try/finally兜底、用IAsyncEnumerable的取消令牌支持断开、确保事件名一致、每轮对话独立构造请求。四、最小可运行复现下面用 C# 伪代码演示连接断开导致剩余片段丢失与兜底后可恢复public async Task StreamStable(string prompt, CancellationToken ct) { try { await foreach (var chunk in _claudia.StreamAsync(prompt) .WithCancellation(ct)) { if (string.IsNullOrEmpty(chunk)) continue; // 用 try 包住单次推送单块失败不影响整体 try { await Clients.Caller.SendAsync(Token, chunk, ct); } catch (Exception) { /* 连接已断记录日志即可 */ } } await Clients.Caller.SendAsync(Done, cancellationToken: ct); } catch (OperationCanceledException) { // 客户端断开正常结束 } finally { // 清理本轮流式状态 } }前端connection.on(Token, (t) out.innerText t); connection.on(Done, () spinner.hidden true);五、解决方案第一层最小直接修复最小修复是给流式管线加异常/取消兜底并确保事件名一致using Claudia; using Microsoft.AspNetCore.SignalR; public class ChatHub : Hub { private readonly Anthropic _anthropic; public ChatHub(Anthropic a) _anthropic a; public async Task Stream(string prompt, CancellationToken ct) { var req new MessageRequest { Model claude-3-5-sonnet-latest, MaxTokens 1024, Messages new[] { new Message { Role user, Content prompt } }, }; try { await foreach (var msg in _anthropic.Messages.StreamAsync(req).WithCancellation(ct)) { var text msg.Delta?.Text ?? ; if (text.Length 0) continue; try { await Clients.Caller.SendAsync(Token, text, ct); } catch { /* 连接断开忽略 */ } } await Clients.Caller.SendAsync(Done, cancellationToken: ct); } catch (OperationCanceledException) { /* 客户端离开 */ } } }要点每轮独立构造MessageRequest单次SendAsync失败不终止整体WithCancellation(ct)让客户端断开能优雅停止。六、解决方案第二层结构化改进把流式管线稳定性做成策略集中处理兜底、取消、事件名from dataclasses import dataclass, field from typing import Callable, Iterable dataclass(frozenTrue) class DotnetSignalrStreamV2Policy: 流式管线稳定性策略异常/取消兜底事件名统一。 规则 - 每个片段独立推送单块失败不影响整体 - 支持取消令牌客户端断开即停 - 事件名集中定义避免前后端不一致 token_event: str Token done_event: str Done def pump(self, chunks: Iterable[str], push: Callable[[str, str], None], is_connected: Callable[[], bool]) - None: try: for chunk in chunks: if not is_connected(): break # 客户端断开停止产出 if chunk: try: push(self.token_event, chunk) except Exception: break # 推送失败停止 if is_connected(): push(self.done_event, ) except Exception: pass # 兜底绝不让管线崩溃 def demo() - None: policy DotnetSignalrStreamV2Policy() sent [] policy.pump([你, 好], lambda e, t: sent.append((e, t)), is_connectedlambda: True) assert sent[-1][0] Done print(稳定流式 OK) if __name__ __main__: demo()七、解决方案第三层断言 / CI 守护import pytest from your_module import DotnetSignalrStreamV2Policy def test_all_chunks_pushed(): policy DotnetSignalrStreamV2Policy() sent [] policy.pump([a, b], lambda e, t: sent.append((e, t)), lambda: True) assert [s for s, _ in sent if s Token] [Token, Token] def test_done_on_success(): policy DotnetSignalrStreamV2Policy() sent [] policy.pump([x], lambda e, t: sent.append((e, t)), lambda: True) assert sent[-1][0] Done def test_stop_when_disconnected(): policy DotnetSignalrStreamV2Policy() sent [] policy.pump([a, b, c], lambda e, t: sent.append((e, t)), lambda: False) # 连接断开不应推送任何 Token assert all(s ! Token for s, _ in sent) def test_push_failure_stops(): policy DotnetSignalrStreamV2Policy() def bad(e, t): raise RuntimeError(send fail) sent [] policy.pump([a, b], bad, lambda: True) # 推送失败应停止不发 Done因为连接已坏 assert ( Done, ) not in sent def test_event_names(): policy DotnetSignalrStreamV2Policy() assert policy.token_event Token and policy.done_event DoneCI 里用模拟IAsyncEnumerable 模拟连接状态跑pump断言连接断开即停、推送失败即停、成功才发 Done。八、排查清单流式迭代是否包了try/finally异常会让 hub 崩、丢剩余片段。是否用WithCancellation(ct)支持客户端断开停止前端connection.on的事件名是否与SendAsync第一个参数完全一致单次SendAsync失败是否会影响整体应单块失败即停而非崩。多轮对话是否每轮独立构造请求避免状态串台。前端是否在connection.start()后才invoke九、小结用 Claudia SignalR 流式输出时即使接了流式库仍不稳定根因是流式管线在传输层被截断——迭代异常让 hub 崩、连接断开导致剩余片段丢失、前后端事件名不一致。最小修复是给流式迭代加try/finally与取消令牌、单块推送失败即停、事件名严格一致结构化做法是抽成DotnetSignalrStreamV2Policy集中兜底最后用 pytest 守护断开即停、失败即停、成功才 Done让多轮对话的流式稳定可靠。