Ohhnews

分类导航

$ cd ..
Baeldung原文

Embabel智能体AI框架中的LLM流式处理

#embabel#llm流式处理#java#智能体ai#结构化输出

[LOADING...]

1. 概述

LLM 流式传输会在生成输出的过程中逐 token 地传递结果。这降低了延迟,因为用户能在几毫秒内看到界面中的首批结果,而无需等待数秒才能获得完整答案。

对于具有结构化输出和推理能力的代理,流式传输应该支持增量处理:

  • 在响应到达时将其解析为 Java 对象,以及
  • 在思考块到达时也对其进行处理

然而,当前主流框架并不支持这些功能。

在本教程中,我们将:

  • 简要回顾这些框架的局限性
  • 并展示如何使用 Embabel Agentic AI 框架 在对象和思考块随流到达时对其进行增量处理

我们将涵盖四种场景:

  • 无推理的单个对象
  • 无推理的多个对象
  • 有推理但无工具调用的多个对象
  • 同时包含推理和工具调用的多个对象

2. 结构化流式传输:框架的局限性

用于 LLM 集成的主流 Java 框架 Spring AI 和 LangChain4j 对流式传输的支持程度有限。当模型生成文本时,我们可以获得实时的文本流,但这两个框架都无法开箱即用地将该文本流转换为类型化对象的集合。

Spring AI 的 entity() 方法可以将模型输出转换为 Java 对象,但它适用于阻塞式 call() 方法,却不适用于 stream() 方法。ChatClient API 参考直接说明了这一点:“未来,我们将提供一个便捷方法,让你可以通过响应式 stream() 方法返回 Java 实体。在此期间,你应该使用结构化输出转换器(Structured Output Converter)。”

LangChain4j 也有同样的局限性。它的 AiServices 仅允许在非流式调用中返回自定义 POJO、列表和枚举。对于流式传输,返回类型必须是 TokenStream,它会随着文本到达而提供原始文本,并提供丰富的回调钩子,但不会在流式输出中提供类型化对象

实际上,任何希望获得带有推理的结构化对象流的人,都必须像这篇 Baeldung 文章 那样自行构建该功能。 这意味着要选择一种以换行符分隔的格式(如 JSONL),将 token 缓冲到一行完整可用,将每一行解析为 JSON,并在此过程中过滤掉任何思考块。实现这一过程会占用实际项目的时间。

这正是 Embabel Agentic AI 发挥作用的地方。它原生提供了所需的功能,我们将在下文中进一步探讨。

3. 环境搭建

首先,让我们定义必要的 Embabel 依赖:

$ xml
<dependency>
    <groupId>com.embabel.agent</groupId>
    <artifactId>embabel-agent-starter-anthropic</artifactId>
    <version>${embabel-agent.version}</version>
</dependency>
<dependency>
    <groupId>com.embabel.agent</groupId>
    <artifactId>embabel-agent-test-internal</artifactId>
    <version>${embabel-agent.version}</version>
    <scope>test</scope>
</dependency>

embabel-agent-starter-anthropic 依赖提供了使用 Embabel 调用 Anthropic 模型所需的核心类。在本教程的示例中,我们使用 Anthropic 模型 claude-sonnet-4-5

此外,我们引入 embabel-agent-test-internal,以便在 @SpringBootTest 中使用 AgentTestApplication 测试支持类。

3.1. Embabel 中的提供商支持

请注意,不同模型提供商在将流式传输与思考、工具结合的方式上存在差异。Anthropic 的原生思考会生成显式的推理块,但其他提供商可能不会以相同方式呈现推理。

Embabel 支持十多个 LLM 提供商,Baeldung 此前曾发布过 使用 OpenAI 的示例

3.2. ParkingRecommendation

在本文的所有测试中,我们将流式传输 ParkingRecommendation 对象:

[LOADING...]

该记录包含四个标量属性:停车场景(scenario)、所选方案成本(estimatedTotalCost)、摘要以及所选方案(枚举 Option)。

4. 流式传输单个对象

首先,我们展示如何在没有推理和工具调用的情况下流式传输单个对象。测试方法为 whenStreaming_thenReceivesParkingRecommendation()

$ java
void whenStreaming_thenReceivesParkingRecommendation() {
     streamParkingRecommendations(PARKING_PROMPT);
  }

PARKING_PROMPT 请求单个停车推荐,私有方法 streamParkingRecommendations() 负责实际的对象流式传输:

$ java
Flux<ParkingRecommendation> stream = new StreamingPromptRunnerBuilder(runner)
  .streaming()
  .withPrompt(PARKING_PROMPT)
  .createObjectStream(ParkingRecommendation.class);
stream
  .timeout(Duration.ofSeconds(120))
  .doOnNext(rec -> {
    received.add(rec);
    logger.info(
      "Received parking recommendation: scenario={}, option={}, cost={}, summary={}",
      rec.scenario(),
      rec.chosenOption(),
      rec.estimatedTotalCost(),
      rec.summary());
    }).blockLast(Duration.ofSeconds(240));

这里:

  • 我们通过调用 createObjectStream() 创建一个 Flux<ParkingRecommendation>Flux 是一种 Reactor 类型,表示随着时间推移到达的包含零个或多个元素的异步序列,而不是一次性全部到达。 在本例中,模型会生成一个 ParkingRecommendation 对象。
  • PARKING_PROMPT 指示 LLM 从三个可用选项中找出最佳停车方案:路边停车、计时停车和车库停车。该提示指定了每个备选方案的约束条件和成本。
  • 该管道使用 doOnNext() 订阅,以收集并记录每个对象。
  • blockLast() 会阻塞调用线程,直到流完成。这在测试环境中是合适的,尽管生产代码会继续链接响应式操作符而不是阻塞。例如,我们可以直接将 Flux 返回给 Web 端点,由端点将响应流式传输给客户端。

我们可以像这样运行单元测试方法 whenStreaming_thenReceivesParkingRecommendation()

$ bash
$ mvn test -pl embabel-streaming -P integration -Dtest=StreamingWithThinkingAndToolingIntegrationTest#whenStreaming_thenReceivesParkingRecommendation

流完成后,日志确认该流传递了一个完整的 ParkingRecommendation 对象:

$ plaintext
17:34:13.781 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Midtown Manhattan parking for 3-hour client meeting with 30-minute arrival buffer, option=GARAGE, cost=90, summary=Choose garage parking. The meeting duration (3 hours) exceeds the metered parking limit (2 hours), eliminating that option. Street parking in Midtown Manhattan is extremely unreliable and searching could make you late for the meeting. The guaranteed spot and ability to stay for the full meeting duration justifies the $90 cost for this professional context.

该对象包含我们期望的所有属性:scenariochosenOptionestimatedTotalCostsummary

5. 流式传输多个对象

streamParkingRecommendations() 方法可以流式传输多个对象。为了演示这一点,我们使用 TIMED_PARKING_PROMPT 请求三个推荐方案:

$ java
void whenStreamingMultipleScenarios_thenReceivesRecommendationPerScenario() {
    streamParkingRecommendations(TIMED_PARKING_PROMPT);
}

在流式传输时:

  • doOnNext() 会收集并记录到达的每个对象
  • blockLast() 会等待流完成

我们可以像这样运行单元测试方法 whenStreamingMultipleScenarios_thenReceivesRecommendationPerScenario

$ bash
$ mvn test -pl embabel-streaming -P integration -Dtest=StreamingWithThinkingAndToolingIntegrationTest#whenStreamingMultipleScenarios_thenReceivesRecommendationPerScenario

我们不会得到包含所有对象的单个批量响应,而是在收到每个 ParkingRecommendation 时,将它作为独立对象获取:

$ plaintext
18:31:24.200 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Early morning (before 8am), option=STREET, cost=0, summary=Arrive before 8am to take advantage of free street parking. Meters are not enforced until 8am, providing zero-cost parking. Be prepared to feed the meter or move to a garage if your meeting extends past 8am.
18:31:27.354 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Business hours (9am-5pm), option=GARAGE, cost=45, summary=For a 3-hour business meeting with only a 30-minute arrival window, a parking garage is the most reliable option. Street parking in Midtown is extremely competitive during peak hours, and the time spent searching could cause you to miss your meeting. Garage rates typically range $35-55 for 3 hours in Midtown Manhattan.
18:31:28.889 [HttpClient-4-Worker-0] WARN  StreamHelper - Unhandled event type: CONTENT_BLOCK_STOP
18:31:28.895 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Evening (after 6pm), option=STREET, cost=0, summary=Street meters become free after 6pm, making this the best option if you're confident your 3-hour meeting will end before 9pm. However, have a backup plan: identify a 24-hour garage nearby in case your meeting runs late, as many garages close at 9pm and you could be locked in or locked out.

日志中每个对象创建的时间戳证实了完整对象是按顺序获取的,并且集合是增量填充的。

请注意日志中的条目“Unhandled event type: CONTENT_BLOCK_STOP”。它只是表示“此内容块(文本块、工具调用块等)已结束”。它不携带任何负载,只是说明我们收到了一个服务器端事件,但没有为它绑定处理器。

6. 带推理的流式传输

首先,我们必须通过为 LlmOptions 配置 token 预算来启用深度思考:

$ java
void whenStreamingWithThinking_thenReceivesReasoningAndRecommendation() {
    LlmOptions thinkingOptions = new LlmOptions().withThinking(Thinking.withTokenBudget(8000));
    PromptRunner runner = ai.withDefaultLlm().withLlm(thinkingOptions);
    streamParkingRecommendationsWithThinking(runner, TIMED_PARKING_PROMPT);
}

该预算控制模型在生成答案之前可以用于推理的 token 数量,并且必须低于 max_tokens(对于 claude-sonnet-4-5 为 8192)。

对象在私有方法 streamParkingRecommendationsWithThinking() 中流式传输。它使用 createObjectStreamWithThinking() 而非 createObjectStream(),返回类型也相应变化:

$ java
Flux<StreamingEvent<ParkingRecommendation>> stream = new StreamingPromptRunnerBuilder(runner)
  .streaming()
  .withPrompt(prompt)
  .createObjectStreamWithThinking(ParkingRecommendation.class);

StreamingEvent 包装了类型化对象和推理片段,因为流会交错出现这两种事件。 doOnNext() 回调通过 event.isObject()event.isThinking() 检查来区分它们,并将它们收集到不同的列表中:received 用于对象,reasoning 用于思考片段:

$ java
.doOnNext(event -> {
  if (event.isObject()) {
      ParkingRecommendation rec = event.getObject();
      if (rec != null) {
          received.add(rec);
          logger.info("Received recommendation: scenario={}, option={}, cost={}, summary={}",
            rec.scenario(), rec.chosenOption(), rec.estimatedTotalCost(), rec.summary());
      }
  } else if (event.isThinking()) {
      reasoning.add(event.getThinking());
      logger.info("Received reasoning: {}", event.getThinking());
  }
})

我们可以按如下方式运行单元测试 whenStreamingWithThinking_thenReceivesReasoningAndRecommendation()

$ bash
$ mvn test -pl embabel-streaming -P integration -Dtest=StreamingWithThinkingAndToolingIntegrationTest#whenStreamingWithThinking_thenReceivesReasoningAndRecommendation

日志表明,推荐结果和推理片段在到达时即被处理。

$ plaintext
21:34:05.311 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received reasoning: The user is asking for parking recommendations for Midtown Manhattan across three different time scenarios. I need to analyze each scenario and provide recommendations in JSONL format.
21:34:05.311 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Let me think through each scenario:
...
21:34:13.300 [boundedElastic-1] INFO StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Early morning (before 8am), option=STREET, cost=0, summary=Street parking is free before 8am in Midtown. Arrive early to secure a spot while meters are not enforced. No cost advantage to using garage or paid meter.
21:34:14.003 [boundedElastic-1] INFO StreamingWithThinkingAndToolingIntegrationTest - Received reasoning: For business hours, considering reliability vs cost. 30-minute window suggests tight schedule, and 3-hour stay during peak hours means garage offers certainty despite higher cost
21:34:16.867 [boundedElastic-1] INFO StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Business hours (9am-5pm, 3-hour stay), option=GARAGE, cost=35, summary=During peak business hours with only a 30-minute arrival window, garage parking ($30-40 for 3 hours) provides guaranteed availability and eliminates time spent searching. Street meters ($12-15 for 3 hours) are cheaper but finding spots in Midtown during business hours is challenging and risky given the tight timeline.
21:34:18.275 [boundedElastic-1] INFO StreamingWithThinkingAndToolingIntegrationTest - Received reasoning: Evening scenario has free street parking but garage closure risk. If arriving at 6pm with 3-hour stay, would exit at 9pm exactly when garages close - cutting it too close. Street parking is free and has no closure risk
21:34:19.337 [HttpClient-4-Worker-0] WARN StreamHelper - Unhandled event type: CONTENT_BLOCK_STOP
21:34:19.359 [boundedElastic-1] INFO StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Evening (after 6pm, 3-hour stay), option=STREET, cost=0, summary=Street meters are free after 6pm, making this the clear choice. Additionally, with garages closing at 9pm and a 3-hour stay, there's significant risk of garage closure before departure. Street parking eliminates both cost and time constraints.

请注意,每个推理块都表示单行内容,因为 Embabel 会将小片段聚合为一行。## 7. 使用工具与推理进行流式处理

最后,让我们将工具调用与流式处理和思考结合起来。对于阻塞式调用,Baeldung 文章 使用 Embabel Agentic AI 框架进行 LLM 工具调用推理 展示了工具调用如何受益于 LLM 推理。

该 API 与推理用例非常相似,不同之处在于工具注册 withToolObject(new ParkingTooling()) 和用于可观测性的日志检查器:

$ java
void whenStreamingWithThinkingAndTooling_thenReceivesRecommendationAndReasoning() {
    PromptRunner runner = ai.withDefaultLlm()
      .withToolObject(new ParkingTooling())
      .withToolCallInspectors(new ToolCallLoggingInspector(LogLevel.INFO, logger));
    streamParkingRecommendationsWithThinking(runner, TOOL_PARKING_PROMPT);
}

TOOL_PARKING_PROMPT 指示模型在给出三条推荐之前主动使用工具,而 streamParkingRecommendationsWithThinking() 方法则对推荐进行流式输出。

该管道使用了相同的 createObjectStreamWithThinking() 方法和相同的 event.isObject() / event.isThinking() 模式。关键区别在于工具调用的时机:模型首先调用工具,然后对结果进行推理,再输出对象,最后提供推理摘要。 由于 Spring AI 会在所有工具调用完成后启动新的流,因此推理块只会在工具调用之后发出。

我们可以像下面这样运行单元测试 whenStreamingWithThinkingAndTooling_thenReceivesRecommendationAndReasoning()

$ bash
$ mvn test -pl embabel-streaming -P integration -Dtest=StreamingWithThinkingAndToolingIntegrationTest#whenStreamingWithThinkingAndTooling_thenReceivesRecommendationAndReasoning
$ plaintext
12:48:52.342 [HttpClient-4-Worker-0] WARN  StreamHelper - Unhandled event type: CONTENT_BLOCK_STOP
12:48:53.275 [boundedElastic-2] INFO  StreamingWithThinkingAndToolingIntegrationTest - beforeToolCall: tool=reserveGarage, argsLength=28
12:48:53.278 [boundedElastic-2] INFO  Embabel - [suspicious_tharp] calling tool reserveGarage({"arg0":"Midtown Manhattan"})
12:48:53.281 [boundedElastic-2] INFO  Embabel - [suspicious_tharp] tool reserveGarage returned Garage reserved near Midtown Manhattan ($30/hour, guaranteed) in 2ms with payload {"arg0":"Midtown Manhattan"}
12:48:53.284 [boundedElastic-2] INFO  StreamingWithThinkingAndToolingIntegrationTest - afterToolCall: tool=reserveGarage, status=Text, resultLength=61, durationMs=6
12:48:55.299 [boundedElastic-3] INFO  StreamingWithThinkingAndToolingIntegrationTest - beforeToolCall: tool=findStreetParking, argsLength=38
12:48:55.299 [boundedElastic-3] INFO  Embabel - [suspicious_tharp] calling tool findStreetParking({"arg1":30,"arg0":"Midtown Manhattan"})
12:48:55.300 [boundedElastic-3] INFO  Embabel - [suspicious_tharp] tool findStreetParking returned Street parking found near Midtown Manhattan (free) in 1ms with payload {"arg1":30,"arg0":"Midtown Manhattan"}
12:48:55.300 [boundedElastic-3] INFO  StreamingWithThinkingAndToolingIntegrationTest - afterToolCall: tool=findStreetParking, status=Text, resultLength=50, durationMs=1
12:48:56.953 [boundedElastic-4] INFO  StreamingWithThinkingAndToolingIntegrationTest - beforeToolCall: tool=findMeterParking, argsLength=38
12:48:56.954 [boundedElastic-4] INFO  Embabel - [suspicious_tharp] calling tool findMeterParking({"arg1":30,"arg0":"Midtown Manhattan"})
12:48:56.954 [boundedElastic-4] INFO  Embabel - [suspicious_tharp] tool findMeterParking returned No metered parking found within 30 minutes in 0ms with payload {"arg1":30,"arg0":"Midtown Manhattan"}
12:48:56.954 [boundedElastic-4] INFO  StreamingWithThinkingAndToolingIntegrationTest - afterToolCall: tool=findMeterParking, status=Text, resultLength=42, durationMs=1
12:48:58.801 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received reasoning: I'll help you find the best parking options for your client meeting in Midtown Manhattan. Let me check all available parking options for you.Based on the parking options available, here are my three recommendations with the best option first:
12:49:00.945 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Advisor needs guaranteed parking for 3-hour meeting in 30 minutes, option=GARAGE, cost=90, summary=Reserve garage parking at $30/hour for 3 hours ($90 total). This is the BEST option because it's guaranteed and ensures you won't be late for your client meeting. With only 30 minutes until the meeting, reliability is critical.
12:49:03.093 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Advisor needs guaranteed parking for 3-hour meeting in 30 minutes, option=STREET, cost=0, summary=Free street parking is available but highly risky. While it costs nothing, finding a spot is uncertain and time-consuming in Midtown Manhattan. Given that arriving late is not acceptable and you only have 30 minutes, this option could jeopardize your meeting.
12:49:03.706 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received recommendation: scenario=Advisor needs guaranteed parking for 3-hour meeting in 30 minutes, option=METER, cost=0, summary=Metered parking is NOT available within your 30-minute timeframe in Midtown Manhattan. This option is not viable for your situation.
12:49:05.244 [HttpClient-4-Worker-0] WARN  StreamHelper - Unhandled event type: CONTENT_BLOCK_STOP
12:49:05.247 [boundedElastic-1] INFO  StreamingWithThinkingAndToolingIntegrationTest - Received reasoning: **Strong Recommendation: Choose the garage parking.** With only 30 minutes before your client meeting and the absolute requirement not to be late, the $90 guaranteed garage spot is the only responsible choice. The cost is a small price compared to the professional consequences of arriving late to a client meeting.

whenStreamingWithThinking_thenReceivesReasoningAndRecommendation() 类似,该流会同时传递一条推荐和一个推理块(在工具完成各自任务之后)。

8. 结论

在本文中,我们展示了如何使用 Embabel Fluent API 生成类型化 Java 对象的流,并透明地处理思考和工具结果。

在 Spring AI 和 LangChain4j 中,流式输出原始文本都很直接,但流式输出类型化对象并不是这两个框架开箱即用的功能。Embabel 在框架层面解决了这个问题,无需手动缓冲令牌、解析 NDJSON 或过滤推理块。 同样的模式既可以从单个对象扩展到集合,也可以从简单提示扩展到多工具推理链。对于既重视结构又重视响应性的应用来说,这消除了原本会落在每个构建智能体 AI 应用的团队身上的样板代码层。

文章代码可在 GitHub 上获取。