猪头少年 - 云南AI专家 - 云南独立开发者

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 当前流式实现需要留意的协议问题

当前代码能作为学习版本,但需要尽快修正:

  1. 正常结束字段应以 finish_reason 为准,而代码检查了不存在的 stop 属性;
  2. 外层循环最多执行 100 次,却没有在正常完成后明确 yield break
  3. PostAsync 默认完成策略不如 ResponseHeadersRead 适合长流;
  4. 没有处理 HTTP 非 2xx、JSON 解析失败、服务端错误事件和连接中断;
  5. 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
  • contenttool_callsfinish_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 放进真实工作流,建议完成本篇的实战作业后再继续:每一步都应留下可验证的代码、测试或运行记录。

Next step

不要停在单篇文章。

沿主专题继续阅读,查看同一问题从概念到实战的完整路径。

返回「Agent 工程实战」路线