agentscope-ai / agentscope-ai/agentscope-java

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

Đang mở
#2,696 2 bình luận 1 reaction 0 người được giao Xem trên GitHub
area/core/model bug
Ngôn ngữ chính
Java
Star
5.6k
Fork
1.3k
Merge trung bình
4 ngày 12 giờ
Pull request đã merge (30 ngày)
77

Mô tả

## 概述

`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`。

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

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.