agentscope-ai / agentscope-ai/agentscope-java

[Bug]: Structured Output Fallback Path 设计缺陷 及 解决方案

Aberta
#2,696 2 comentários 1 reação 0 responsáveis Ver no GitHub
area/core/model bug
Linguagem predominante
Java
Estrelas
5.6k
Forks
1.3k
Merge médio
4d 12h
PRs com merge (30d)
77

Descrição

## 概述

`ReActAgent` 的结构化输出降级路径(`doFallbackStructuredCall`)存在两个设计缺陷,导致在某些模型上结构化输出不可靠。

**影响范围**:所有走降级路径的场景。目前除 OpenAI 直连外,所有模型提供商(DashScope、DeepSeek、GLM、MiniMax、Anthropic、Ollama、Gemini)都走此路径。

**相关源码**:`ReActAgent.doFallbackStructuredCall`。

---

## 各 Provider 的 `tool_choice` 支持矩阵

在讨论修复方案之前,先梳理各模型 API 对 `tool_choice` 参数的支持情况,特别是 `ToolChoice.Specific("generate_response")` 强制调用指定工具的能力(以下数据来自当前源码 `applyToolChoice` 实现):

| Provider | Formatter | useNative(默认) | `auto` | `ToolChoice.Specific("name")` | 关键说明 |
| -------------- | ---------------------------------------------------------------------------------------------------------------- | :---------------: | :------: | :---------------------------: | -------------------------------- |
| **OpenAI** | `OpenAIChatFormatter` | ✅ | ✅ | ✅ | 全支持,标准实现 |
| **DeepSeek** | `DeepSeekFormatter`(extends OpenAIChatFormatter,未覆写 `applyToolChoice`) | ❌ | ✅ | ✅ | 继承父类 |
| **Anthropic** | `AnthropicToolsHelper` | ❌ | ✅ | ✅ | 通过 `ToolChoiceTool` 实现 |
| **Gemini** | `GeminiToolsHelper` | ❌ | ✅ | ✅ | 通过 `allowedFunctionNames` 实现 |
| **DashScope** | `DashScopeToolsHelper` | ❌ | ✅ | ✅ | 仅 `required` 不支持 |
| **Ollama** | `OllamaToolsHelper` | ❌ | ✅ | ✅ | 仅 `required` 不支持 |
| **GLM (智谱)** | `GLMFormatter` | ❌ | ✅ | ❌→`auto` | **仅支持 `auto`**,其他全部降级 |
| **MiniMax** | `MiniMaxFormatter` | ❌ | ❌→`null` | ❌→`null` | **完全不支持 `tool_choice`** |

**`useNative` 说明**:指 provider 默认是否启用原生结构化输出(`nativeStructuredOutput`),值为 ✅ 时走原生路径(`response_format`),❌ 时走降级路径(`generate_response` 工具)。

| Provider | 默认值来源 |
| --------------------------- | ----------------------------------------------------------------------------------------------- |
| OpenAI | `.nativeStructuredOutput(true)` |
| DeepSeek | `.nativeStructuredOutput(false)` |
| GLM | `.nativeStructuredOutput(false)` |
| MiniMax | `.nativeStructuredOutput(false)` |
| DashScope | 默认不设置(`null` → false),可选项开启;thinking 模式强制 false |
| Anthropic / Ollama / Gemini | 无 `nativeStructuredOutput` 配置项,`ChatModelBase` 默认 `null` → false |

**结论**:

1. 除 OpenAI 外,所有 provider 默认都走**降级路径**(`generate_response` 工具)。
2. `tool_choice` 层面,6/8 的 provider 支持 `ToolChoice.Specific("generate_response")`;GLM 和 MiniMax 不支持,需要走 prompt 降级路径。

---

## Issue 1:`generate_response` 工具调用非强制,结果不确定

### 问题

`generate_response` 在降级路径中与其他业务工具地位完全相同——都只是传递给 LLM 的 tools 列表中的一项:

```java
// 每调用一次的结构化输出工具:仅对本次调用暴露 generate_response(不注册到共享 toolkit)。
if (soTool != null) {
tools = new ArrayList<>(tools);
tools.add(
ToolSchema.builder()
.name(soTool.getName())
.description(soTool.getDescription())
.parameters(soTool.getParameters())
.strict(soTool.getStrict())
.outputSchema(soTool.getOutputSchema())
.build());
}
```

LLM 可以自由选择是否调用它。如果 LLM 输出一段自由文本而不调用任何工具,`isFinished()` 返回 `true`,ReAct 循环正常结束,但结果中不包含结构化数据:

```java
private boolean isFinished(Msg msg) {
if (msg == null) {
return true;
}

List toolCalls = msg.getContentBlocks(ToolUseBlock.class);

// 无工具调用 → 结束
// 即使存在工具调用(哪怕是不存在的工具),也继续进入 acting 阶段,
// 由 ToolExecutor 返回 "Tool not found" 错误供模型看到
return toolCalls.isEmpty();
}
```

### 为什么这是问题

- **非确定性行为**:同一个请求,有些轮次模型会调用 `generate_response`,有些轮次不会。调用方无法预期是否拿到结构化数据。
- **静默失败**:循环正常结束但不含结构化数据,`msg.getStructuredData()` 会抛出 `IllegalStateException`,但没有提前的 warning 或 fallback 机制。
- **已有骨架但未落地**:`StructuredOutputReminder` 枚举定义了 `TOOL_CHOICE` 和 `PROMPT` 两种强制策略,但在代码中零引用。

### 建议方案:两路降级策略

核心思路:在 `runPostReasoningPipeline()` 中,当 `isFinished() == true` 且 `soCompleted == false`(即 ReAct 循环要结束了但 `generate_response` 还没被调用),触发强制机制。强制重试以 3 次为上限,超过上限后放弃强制、正常结束,避免模型始终不调用 `generate_response` 时陷入死循环。**(实际上增强系统提醒后一次一般就能正常,3 次只是兜底)**

**在 `CallExecution` 中添加状态标记**:

```java
/** 每调用一次的结构化输出工具({@code generate_response} 工具)。仅降级路径
* (模型不支持原生结构化输出时)非 null。放在此作用域而非共享 toolkit,避免并发
* 结构化输出调用相互冲突。
*/
AgentTool soTool;

/** {@code generate_response} 工具成功完成时置为 {@code true}。 */
boolean soCompleted;

/** 成功 {@code generate_response} 调用的工具结果消息。 */
Msg soResultMsg;

/**
* 强制调用 {@code generate_response} 的累计次数。{@code 0} = 从未强制;
* 大于 {@code 0} 时下一轮 reasoning 消费该标记强制 {@code tool_choice}(或 prompt 提醒),
* 该值同时作为重试上限:达到 {@code 3} 时停止强制、无结构化数据地结束,避免死循环。
*/
int soForceToolChoiceCount;

/** 原生路径调用在 per-call 作用域上设置的原生结构化输出格式。 */
ResponseFormat nativeResponseFormat;
```

> `soForceToolChoiceCount` 为 per-call 字段,每次 `beforeAgentExecution` 创建全新 `CallExecution`,天然随调用重置,无需显式清零。

**在 `runPostReasoningPipeline()` 中添加拦截逻辑**:

```java
// 检查结束条件
if (isFinished(eventMsg)) {
// 结构化输出仍未完成:强制模型调用 generate_response(Issue 1)。
// 最多重试 3 次,超过后放弃,避免模型始终拒绝调用时陷入死循环。
if (soTool != null && !soCompleted) {
if (soForceToolChoiceCount < 3) {
soForceToolChoiceCount++;
return reasoning(iter + 1, true);
}
log.warn(
"Structured output tool '{}' not called after {} "
+ "forced retries; finishing without "
+ "structured data (agent={}#{})",
STRUCTURED_OUTPUT_TOOL_NAME,
soForceToolChoiceCount,
getName(),
getAgentId());
}
return Mono.justOrEmpty(eventMsg);
}

// 继续执行 acting
return checkInterrupted().then(acting(iter));
```

**在 `reasoning()` 中根据 provider 能力选择策略**:

```
soForceToolChoiceCount > 0?

├─ Provider 支持 ToolChoice.Specific?
│ └─→ options.toolChoice(ToolChoice.Specific("generate_response"))
│ // API 层硬约束,模型必须调用 generate_response

└─ Provider 不支持 ToolChoice.Specific?(GLM、MiniMax)
└─→ 注入 reminder 消息到上下文
// 提示模型必须调用 generate_response 工具来输出最终答案
```

#### 策略 A:`ToolChoice.Specific`(6/8 provider 适用)

在 `reasoning()` 中构建 `GenerateOptions` 的位置消费标记(原有 `nativeResponseFormat` 逻辑之后):

```java
GenerateOptions options =
event.getEffectiveGenerateOptions() != null
? event.getEffectiveGenerateOptions()
: buildGenerateOptions();
if (nativeResponseFormat != null && soTool == null) {
options =
GenerateOptions.mergeOptions(
GenerateOptions.builder()
.responseFormat(nativeResponseFormat)
.build(),
options);
}
// 上一轮结束时未调用 generate_response 则强制其调用。
// 与上方 nativeResponseFormat 互斥:soForceToolChoiceCount 仅在降级路径
// (soTool != null)被置位,此时 nativeResponseFormat 必为 null。
if (soForceToolChoiceCount > 0
&& soTool != null
&& model.supportsToolChoiceSpecific()) {
options =
GenerateOptions.mergeOptions(
GenerateOptions.builder()
.toolChoice(
new ToolChoice.Specific(
STRUCTURED_OUTPUT_TOOL_NAME))
.build(),
options);
}
```

#### 策略 B:Prompt 注入(GLM、MiniMax 降级路径)

当 provider 不支持 `ToolChoice.Specific` 时,在 `reasoning()` 开头(`firePreReasoning` 之前)注入一条 system 提醒消息——必须在 `firePreReasoning` 之前注入,因为 `PreReasoningEvent` 会对 context 列表做防御性拷贝,晚注入的消息在本轮对模型不可见。这是 finish 阶段的**兜底强制**,与 Issue 2 在 `doCallInner` 入口注入的**进入提示**各司其职、共存而非互相替代:

```java
// 基于 prompt 的强制提醒(不支持 ToolChoice.Specific 的 provider,如 GLM / MiniMax):
// 必须在 firePreReasoning 之前注入,使其被纳入 input-messages 快照——
// PreReasoningEvent 会防御性拷贝 context 列表,晚注入的消息在本轮对模型不可见。
if (soForceToolChoiceCount > 0
&& soTool != null
&& !model.supportsToolChoiceSpecific()) {
state.contextMutable().add(buildSoToolForceReminder());
}
```

提醒消息由独立方法 `buildSoToolForceReminder()` 构建,包裹 `` 标签,并打上 `STRUCTURED_OUTPUT_REMINDER` / `STRUCTURED_OUTPUT_REMINDER_TYPE` 标记:

```java
private Msg buildSoToolForceReminder() {
return SystemMessage.builder()
.name("system")
.content(TextBlock.builder()
.text("\n"
+ "You MUST call the `" + STRUCTURED_OUTPUT_TOOL_NAME
+ "` tool to generate your final structured response. Do NOT "
+ "output free-form text as the final answer.\n"
+ "")
.build())
.metadata(Map.of(
MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER, true,
MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER_TYPE,
StructuredOutputReminder.PROMPT,
MessageMetadataKeys.CACHE_CONTROL, false))
.build();
}
```

消息复用现有 key `STRUCTURED_OUTPUT_REMINDER`,调用结束后由 `compressStructuredOutputContext` 通过 `isStructuredOutputRelated` 清理;同时打上 `STRUCTURED_OUTPUT_REMINDER_TYPE = StructuredOutputReminder.PROMPT` 标记(仅作信息用途,不参与清理判定),并**主动设置 `CACHE_CONTROL = false`** 排除缓存(瞬态消息,被缓存后清理时会导致前缀缓存失效)。

**判断 provider 是否支持 `ToolChoice.Specific` 的方式**:新增一个模型能力标记 `supportsToolChoiceSpecific()`,默认 `true`,仅不支持 `Specific` 的 provider(GLM、MiniMax)覆写为 `false`。

---

## Issue 2:so tool 模式无进入/退出提示,模型缺乏上下文信号

### 问题

开发者通过 API 明确指定了输出 schema:

```java
agent.call(msgs, WeatherResponse.class).block();
```

但框架没有将此意图转化为 LLM 可见的指令。`generate_response` 工具的唯一自然语言提示只是工具描述:

```java
public String getDescription() {
return "Generate the final structured response. Call this function when"
+ " you have all the information needed to provide a complete answer.";
}
```

这只是对工具用途的元描述,不是对模型的显式指令。模型需要自行推断"我应该调用这个工具来输出结构化 JSON",而非被告知"你必须这样做"。

**本质是「注入工具 ≠ 工具被调用」**:框架只是在 tools 列表里多放了一项 `generate_response`,是否调用它完全由大模型自主决定。工具描述只是被动的能力说明,没有任何信号把「当前必须产出结构化 JSON」这一意图显式传达给模型——在 function calling 训练偏好、工具数量较多、或用户要求"直接给出答案"的场景下,模型很容易绕过该工具、直接输出自由文本,结构化输出随之落空。

**对照 plan mode 的进入/退出机制**:plan mode 同样只是「多几个工具 + 一个只读约束」,但它不依赖工具描述暗示——`PlanModeMiddleware.onSystemPrompt` 在进入时注入显式的 `` banner(`PLAN MODE is active (read-only)...`,明确要求「Record your plan with the plan_write tool... call plan_exit when ready」),在退出时注入 `BUILD_MODE_PLAN_HINT`(`You have switched from PLAN to BUILD mode...`)。正是这段系统提醒让模型能从时间线上明确感知当前处于哪种模式、该调用哪个工具。而结构化输出降级路径只注入了工具、缺少等价的系统提醒——这正是本 Issue 要补齐的缺口:在注入 `generate_response` 工具时,同步注入进入/退出系统提醒。

### 为什么这是问题

- **违反最小惊讶原则**:开发者认为 `call(msgs, Schema.class)` 是唯一需要指定输出的地方,但实际上系统提示词中若不补充说明,模型可能单纯输出自由文本而不调用 `generate_response`。
- **增加使用门槛**:开发者需要理解内部实现机制(`generate_response` 合成工具)并手动编写提示词,破坏了 API 的封装性。
- **不同模型表现不一致**:模型的 function calling 训练偏好不同,对工具描述暗示的响应度也不同。

### 现有测试佐证

所有结构化输出的单元测试使用的 system prompt 仅为 `"You are a weather assistant"`,完全没有结构化输出相关指令。测试能通过是因为 MockModel **手动构造**了 `generate_response` tool call,而非 LLM 自然行为。

### 建议方案:进入/退出提示 + 不清理(参考 plan mode)

在 so tool(`generate_response` 工具)模式的**进入**与**退出**时刻各注入一条提示消息,但**不清理**——用「进入 → 多轮对话 → 退出」的时间线让模型自行感知当前是否处于 so tool 模式。参考代码库已有的 plan mode 进入/退出机制(`PlanModeContextState`、`PlanModeMiddleware.onSystemPrompt`)。

> 原方案「入口注入 + 结束后清理」有致命缓存缺陷:指令注入发生在 `doCallInner` 之前(call 入口),清理发生在循环结束后。此时指令早已被 1 小时 ReAct 循环产生的大量消息推到中间,删除它 = 删除中间消息,会令其后所有消息的最长公共前缀缓存全部失效。新方案「注入但不清理」保持消息序列稳定,前缀缓存持续命中。

**状态:`AgentState` 新增单个 `boolean soToolActive` 字段**(默认 `false`,跨 call 持久、可序列化)。该场景只需一个二进制标记,远没有 plan mode(`planActive` + `currentPlanFile` 两个字段)复杂,不必引入独立状态类——直接类比 `AgentState` 已有的裸布尔字段 `shutdownInterrupted`:

```java
// 字段(默认 false,无需构造器初始化)
private boolean soToolActive;

// 访问器(类比 isShutdownInterrupted() / setShutdownInterrupted())
@JsonProperty("so_tool_active")
public boolean isSoToolActive() {
return soToolActive;
}

public void setSoToolActive(boolean soToolActive) {
this.soToolActive = soToolActive;
}
```

同步补:`@JsonPropertyOrder` 增 `"so_tool_active"`;`fromJson` 增 `@JsonProperty("so_tool_active") Boolean soToolActive` 参数并 `if (soToolActive != null) b.soToolActive(soToolActive)`;`Builder` 增 `boolean soToolActive` 字段与方法;`equals`/`hashCode` 纳入该字段。

**进入/退出判断:集中在 `doCallInner` 入口**(普通 / 原生 / 降级三条路径都经过,且此时 `soTool` 已赋值完毕——降级非 null,其余 null):

```java
private Mono doCallInner(List msgs) {
// 结构化输出工具模式的进入/退出提醒(Issue 2):在临界切换处注入一次性
// 并翻转持久标记,使连续同模式调用不重复注入。
// 只有降级路径会设置 soTool,因此原生结构化输出永不触发这些。
// 与 finish 阶段的强制提醒(带 STRUCTURED_OUTPUT_REMINDER 元数据,完成后清理)不同,
// 这些提醒不带元数据、永不清理。
if (soTool != null && !state.isSoToolActive()) {
state.contextMutable().add(buildSoToolEnterReminder());
state.setSoToolActive(true);
} else if (soTool == null && state.isSoToolActive()) {
state.contextMutable().add(buildSoToolExitReminder());
state.setSoToolActive(false);
}
// ... 原有 graceful-shutdown / pending-tool 逻辑
}
```

> 仅降级路径触发:原生结构化输出(`response_format`)不产生 `soTool`,故 `soTool == null`,不会注入进入/退出提示。只有走降级路径、需要引导模型调用 `generate_response` 工具时,`soToolActive` 才会被置位。

**提示词构建**:

```java
private Msg buildSoToolEnterReminder() {
return SystemMessage.builder()
.name("system")
.content(TextBlock.builder()
.text("\n"
+ "STRUCTURED OUTPUT mode is now active. Your final response MUST"
+ " be produced by calling the `" + STRUCTURED_OUTPUT_TOOL_NAME
+ "` tool with a JSON object that conforms to the required "
+ "output schema. Do NOT output free-form text as the final "
+ "answer.\n"
+ "")
.build())
.build();
}

private Msg buildSoToolExitReminder() {
return SystemMessage.builder()
.name("system")
.content(TextBlock.builder()
.text("\n"
+ "STRUCTURED OUTPUT mode has ended. You are back in normal "
+ "conversation mode; you no longer need to call the `"
+ STRUCTURED_OUTPUT_TOOL_NAME
+ "` tool.\n"
+ "")
.build())
.build();
}
```

`soToolActive` 状态跨 call 持久,因此系统提醒**只在临界切换时注入、不会重复**。对同一个 agent 实例:

- **连续多次带 schema 的调用**:第一次调用 `soTool` 由 `null` 变非 null,注入进入提示并把 `soToolActive` 置 `true`;此后每轮 `soTool != null && state.isSoToolActive()` 均不满足,不再重复注入。
- **从无 schema 到有 schema(进入临界)**:`soTool` 由 `null` 变非 null,注入进入提示。
- **从有 schema 到无 schema(退出临界)**:`soTool` 由非 null 变 `null`,注入退出提示并把 `soToolActive` 置回 `false`。

即进入/退出提示各只出现一次,中间连续的同类型调用不产生任何额外提醒。

Guia de contribuição

Abrir o guia de contribuição

Avaliação

Esta issue ainda não foi avaliada.

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.