LLM 的流式接口早已不只是连续吐出文本。一次连接中可能交错出现正文、思考过程、工具参数、引用、媒体、安全事件、用量快照、状态变化和错误。Solon AI 4.1 对流式接口的调整,关键不在于换一个返回类型,而是把这些不同语义的数据从“部分 ChatResponse”中拆出来,让调用方按事件类型处理过程数据与最终结果。
为什么部分 ChatResponse 开始失效
把每一帧都包装成 ChatResponse,适合只有文本增量的简单模型:收到一段文本,追加到页面,结束时再拼成完整答案。
当模型能力扩展后,这个抽象会迅速变得含糊。例如,同一条流里可能出现:
- 正文增量,需要显示给用户;
- 推理或思考增量,可能只用于调试,不能直接展示;
- 工具调用参数,需要累积成合法 JSON 后再执行;
- 引用信息,需要挂到对应答案片段上;
- 图片、音频等媒体,需要走独立渲染流程;
- 用量快照和完成状态,需要进入计费与可观测系统;
- 安全拦截和错误,需要立即改变连接及 UI 状态。
如果这些内容都塞进一个响应对象,调用方只能检查大量可空字段。UI、Agent 和网关还会被迫理解某家供应商的帧格式,最终形成遍布业务代码的 if/else 协议解析器。
语义化事件采用相反的边界:框架先解释供应商协议,再向上游发布稳定的事件。调用方关心的是“这是文本”“这是工具参数”“这次响应完成了”,而不是某个 JSON 字段恰好出现在哪里。
事件流与最终结果是两种数据
过程事件和最终结果承担不同职责,不应该混为一谈。
事件流适合驱动实时行为:增量刷新文本、拼接工具参数、记录用量变化、更新状态指示器。最终结果则适合持久化、缓存、会话回放和下一轮上下文构造。前者允许暂态与不完整,后者应当具备明确的一致性边界。
一个稳妥的消费模型通常包含三层:
- 协议适配层:把不同供应商的 SSE、JSON 帧或 SDK 回调转换为统一事件。
- 事件处理层:根据事件类型更新文本缓冲区、工具调用状态、引用集合和指标。
- 结果归并层:只在完成事件到达后生成可持久化的最终响应;错误或取消时保留必要诊断信息,但不伪造成功结果。
这种拆分也给未知事件留下了空间。模型平台会继续增加事件类型,消费者不应因为遇到一个暂不认识的事件就让整条连接崩溃。更合理的策略是记录事件类型和请求 ID,再按兼容性策略忽略、转发或降级处理。
可以这样实践:实现一个可运行的事件归并器
下面是一个不依赖具体 Solon AI 类名的 Java 17 示例,用来演示语义化事件的消费方式。它是可运行的参考模型;接入 Solon AI 4.1 时,将示例事件映射为项目中实际提供的事件类型即可。
保存为 SemanticChatDemo.java:
import java.util.ArrayList;
import java.util.List;
public class SemanticChatDemo {
sealed interface ChatEvent permits TextDelta, ToolArgsDelta,
UsageSnapshot, Completed, Failed {}
record TextDelta(String text) implements ChatEvent {}
record ToolArgsDelta(String callId, String jsonPart) implements ChatEvent {}
record UsageSnapshot(int inputTokens, int outputTokens) implements ChatEvent {}
record Completed(String finishReason) implements ChatEvent {}
record Failed(String code, String message) implements ChatEvent {}
static final class ChatAccumulator {
private final StringBuilder text = new StringBuilder();
private final StringBuilder toolArgs = new StringBuilder();
private final List<UsageSnapshot> usage = new ArrayList<>();
private boolean completed;
void accept(ChatEvent event) {
switch (event) {
case TextDelta e -> {
text.append(e.text());
System.out.print(e.text());
}
case ToolArgsDelta e -> toolArgs.append(e.jsonPart());
case UsageSnapshot e -> usage.add(e);
case Completed e -> {
completed = true;
System.out.println("\nfinishReason=" + e.finishReason());
}
case Failed e -> throw new IllegalStateException(
e.code() + ": " + e.message());
}
}
String finalText() {
if (!completed) {
throw new IllegalStateException("stream is not completed");
}
return text.toString();
}
String toolArguments() {
return toolArgs.toString();
}
}
public static void main(String[] args) {
List<ChatEvent> stream = List.of(
new TextDelta("北京今天可能下雨,"),
new ToolArgsDelta("weather-1", "{\"city\":"),
new ToolArgsDelta("weather-1", "\"北京\"}"),
new UsageSnapshot(18, 7),
new TextDelta("出门请带伞。"),
new Completed("stop")
);
ChatAccumulator accumulator = new ChatAccumulator();
stream.forEach(accumulator::accept);
System.out.println("finalText=" + accumulator.finalText());
System.out.println("toolArgs=" + accumulator.toolArguments());
}
}
直接编译运行:
javac --release 17 --enable-preview SemanticChatDemo.java
java --enable-preview SemanticChatDemo
这里有两个值得保留的约束:工具参数在完成前只是字符串分片,不应边接收边执行;最终文本也只能在完成事件之后作为成功结果读取。生产环境还应按 callId 分别维护多个工具调用缓冲区,并使用 JSON 解析器验证完整参数,而不是手工拼接后直接信任。
接入 UI、Agent 与网关时怎么分工
UI 不必订阅所有事件。它通常只处理可展示的正文、引用、媒体、完成状态和用户可理解的错误;思考过程、安全审计细节与原始供应商载荷不应默认进入浏览器。
Agent 更关心工具调用生命周期。参数增量到达时只缓存,工具调用完成后再校验 schema、执行授权检查并调用工具。若模型同时发起多个调用,必须以调用 ID 隔离状态,不能共用一个参数缓冲区。
网关则适合保存统一事件日志和指标,但要控制数据边界。思考过程可能包含敏感上下文,工具参数可能带有密钥或个人信息,因此日志系统需要字段级脱敏、容量限制和保留周期。对于高频文本增量,还应批量刷新或采样指标,避免每个 Token 都触发数据库写入。
升级前检查这些边界
迁移到语义化事件时,不要只把旧回调参数换成新类型。建议逐项确认:
- 文本渲染是否只消费正文事件,而不会误显思考或工具参数;
- 工具参数是否按调用 ID 聚合,并在完整、校验通过后才执行;
- 完成、错误、取消和超时是否具有互斥且可观察的终态;
- 未知事件是否有兼容策略,而不是直接抛出不可恢复异常;
- 最终响应是否只生成一次,并与过程事件分别存储;
- 日志是否对思考内容、工具参数和供应商原始数据做了脱敏;
- 下游消费速度不足时,是否具备背压、缓冲上限或主动取消机制。
Solon AI 4.1 的这类接口变化,本质上是在承认一个事实:聊天连接已经是一条多模态、可执行、可观测的事件流。把语义边界放进框架层,可以减少供应商协议向业务代码泄漏,也让 UI、Agent 和网关各自只处理真正属于自己的事件。