Engineering note
模型调用与流式输出:正确处理 SSE 和增量事件
这是「Agent 工程实战」的第 4 篇。专题从一个能聊天、能调工具的 .NET Agent 出发,逐步补齐可靠性、安全、测试与发布能力。
本篇要解决的问题: 拆解 OpenAI 兼容协议中的流式响应、消息历史与工具调用片段。
本章定位:本章深入模型 HTTP 请求、SSE、增量文本和增量工具调用。你会看到一个流式解析器为什么需要独立于 UI,以及如何把网络数据转换成可测试的领域事件。
建议阅读方式:先通读原理,再对照当前项目源码,最后完成本章实践。建议边读边记录服务端可能返回的异常事件,因为流式协议最难的部分通常不在理想示例里。
本章导读
本章深入模型 HTTP 请求、SSE、增量文本和增量工具调用。你会看到一个流式解析器为什么需要独立于 UI,以及如何把网络数据转换成可测试的领域事件。
本章采用“源码观察 → 概念拆解 → 工程改造 → 实践验证”的顺序。示例中的接口和代码骨架用于说明设计方向,真正提交代码时应结合项目当前状态逐步落地。
4.1 请求体
当前代码发送的请求大致如下:
{
"model": "mimo-v2.5",
"messages": [
{"role":"system","content":"你是一个资深的软件开发工程师……"},
{"role":"user","content":"请读取文件"}
],
"tools": [],
"stream": true
}
模型名称、消息、工具描述和 stream 是请求的四个核心部分。
4.2 SSE 是什么
流式接口通常返回一行一条的 Server-Sent Events:
data: {"choices":[{"delta":{"content":"你好"}}]}
data: {"choices":[{"delta":{"content":",世界"}}]}
data: [DONE]
当前实现逐行读取 data: ,解析 JSON,并把 delta.content 通过 yield return 输出给上层。IAsyncEnumerable<string> 很适合把网络流转换成可消费的异步字符流。
4.3 文本和工具调用要分开处理
同一个流中可能同时出现文本增量和工具调用增量。文本可以立即显示,工具调用则必须先累积完整:
工具名:ReadFile
参数片段:{"file
参数片段:Path":"a.md"}
完整参数:{"filePath":"a.md"}
因此代码中用了 Dictionary<int, ...>,按 index 累积每一个并行工具调用。
4.4 当前流式实现需要留意的协议问题
当前代码能作为学习版本,但需要尽快修正:
- 正常结束字段应以
finish_reason为准,而代码检查了不存在的stop属性; - 外层循环最多执行 100 次,却没有在正常完成后明确
yield break; PostAsync默认完成策略不如ResponseHeadersRead适合长流;- 没有处理 HTTP 非 2xx、JSON 解析失败、服务端错误事件和连接中断;
reasoning_content直接写到控制台,没有统一的输出事件模型。
后续章节会把“字符串流”升级为“结构化事件流”。
4.5 推荐的事件模型
public abstract record AgentEvent;
public record TextDelta(string Text) : AgentEvent;
public record ToolCallStarted(string Id, string Name) : AgentEvent;
public record ToolCallFinished(string Id) : AgentEvent;
public record ToolResultReceived(string Id, string Content) : AgentEvent;
public record TurnCompleted(string Reason) : AgentEvent;
这样控制台、Web UI、日志和测试都可以订阅同一种事件,而不用从字符串中猜测发生了什么。
4.6 本章小结
流式输出解决的是体验问题,工具调用解决的是行动问题。二者混在同一条网络流中时,必须先建立清晰的事件和状态边界。
4.7 从网络字节到领域事件
SSE 解析器不应该直接 Console.Write。网络层只应该回答“服务端发来了什么”,而不应该决定“用户界面如何展示”。
建议分成三层:
HTTP 字节流 -> SSE Parser -> ModelEvent -> Agent Loop -> UI/Event Sink
例如,解析器负责把一行 JSON 转为:
public abstract record ModelEvent;
public sealed record ModelTextDelta(string Text) : ModelEvent;
public sealed record ModelToolDelta(
int Index,
string? Id,
string? Name,
string? Arguments) : ModelEvent;
public sealed record ModelFinished(string? FinishReason) : ModelEvent;
这样可以对解析器做纯单元测试,而不需要启动控制台或真实模型服务。
4.8 SSE 解析器的边界情况
真实服务不一定严格按照最理想的示例返回数据。解析器应考虑:
- 空行和注释行;
data:后面为空格或没有空格;- JSON 被拆成多行的情况;
- 服务端发送
error事件; - 一条事件中没有
choices; content、tool_calls或finish_reason缺失;- 连接在
[DONE]之前中断。
第一版可以只支持项目使用的协议,但要把“不支持”变成明确异常,而不是空引用异常。
4.9 流式输出中的背压
如果模型输出速度快于 UI 处理速度,事件可能在内存中堆积。控制台通常不会明显遇到这个问题,但 WebSocket、桌面 UI 或日志系统可能遇到。
可以通过 Channel<AgentEvent> 建立有限容量的事件队列:
var channel = Channel.CreateBounded<AgentEvent>(100);
生产者负责写入,消费者负责展示。当队列满时,要明确是等待、丢弃低价值事件,还是终止请求。不要让这件事由线程调度偶然决定。
4.10 本章练习:写一个脱离模型的 SSE 测试
准备三行文本:两行文本增量、一行 [DONE],断言解析器产生两个 ModelTextDelta 和一个完成事件。再增加一个工具调用参数被拆成三段的用例,断言三段最终可以被正确拼接。
4.11 本章产出
本章的最终成果不是“控制台能显示文字”,而是一个独立、可测试、可替换的流式协议解析层。
单篇实战作业
实践:准备一组正常 SSE、一组截断 SSE 和一组非法 JSON,定义解析器在每种情况下必须产生的事件。
建议把作业拆成一个独立提交,并在提交说明中写清楚:改动前的行为、改动后的行为、验证命令、尚未解决的风险。建议边读边记录服务端可能返回的异常事件,因为流式协议最难的部分通常不在理想示例里。
章节复盘
复盘问题:UI 是否依赖了某个 provider-specific 字段?如果更换模型,哪些层应该完全不变?
本章的完成标准不是把所有设计一次性做完,而是能把它变成项目中的一个明确边界,并为下一章留下可验证的接口。
下一篇:第 5 篇《工具调用协议:让模型能可靠地使用你的能力》
如果你正在把 Agent 放进真实工作流,建议完成本篇的实战作业后再继续:每一步都应留下可验证的代码、测试或运行记录。