fix(agent): complete RFC-001 — Anthropic thinking, iteration budget, UI fixes

This commit is contained in:
matevip 2026-04-12 17:28:15 +08:00
parent 2a8b90365b
commit 46f77c281b
5 changed files with 129 additions and 40 deletions

View File

@ -37,6 +37,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux;
import vip.mate.agent.ThinkingLevelHolder;
import vip.mate.agent.graph.StateGraphReActAgent;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
@ -940,18 +941,40 @@ public class AgentGraphBuilder {
if (StringUtils.hasText(runtimeModel.getModelName())) {
builder.model(runtimeModel.getModelName());
}
// Anthropic API does not allow temperature and top_p to be specified simultaneously.
// Prefer temperature; only fall back to top_p when temperature is absent.
if (runtimeModel.getTemperature() != null) {
builder.temperature(runtimeModel.getTemperature());
} else if (runtimeModel.getTopP() != null) {
builder.topP(runtimeModel.getTopP());
}
if (runtimeModel.getMaxTokens() != null) {
builder.maxTokens(runtimeModel.getMaxTokens());
// Extended thinking: 通过 ThinkingLevelHolder 获取请求级思考深度
String thinkingLevel = ThinkingLevelHolder.get();
boolean thinkingEnabled = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
if (thinkingEnabled) {
// Anthropic thinking 模式下temperature 必须为 1不能设 top_p
// budget_tokens 根据级别映射
int budgetTokens = switch (thinkingLevel.toLowerCase()) {
case "low" -> 4096;
case "medium" -> 8192;
case "high" -> 16384;
case "max" -> 32768;
default -> 16384;
};
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
// Thinking 模式要求 max_tokens 足够大 thinking tokens
builder.maxTokens(Math.max(budgetTokens + 4096,
runtimeModel.getMaxTokens() != null ? runtimeModel.getMaxTokens() : 8192));
// Anthropic thinking 模式要求 temperature=1
builder.temperature(1.0);
} else {
// Anthropic requires max_tokens; set a safe default
builder.maxTokens(4096);
// thinking 模式正常设置参数
// Anthropic API does not allow temperature and top_p to be specified simultaneously.
if (runtimeModel.getTemperature() != null) {
builder.temperature(runtimeModel.getTemperature());
} else if (runtimeModel.getTopP() != null) {
builder.topP(runtimeModel.getTopP());
}
if (runtimeModel.getMaxTokens() != null) {
builder.maxTokens(runtimeModel.getMaxTokens());
} else {
builder.maxTokens(4096);
}
}
return builder.internalToolExecutionEnabled(false).build();
}

View File

@ -365,8 +365,11 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(WORKSPACE_BASE_PATH, workspaceBasePath != null ? workspaceBasePath : "");
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
inputs.put(MESSAGES, messages);
// 迭代控制
inputs.put(MAX_ITERATIONS, maxIterations);
// 迭代控制深度思考模式允许更多迭代思考需要更多轮工具调用
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
int effectiveMaxIterations = thinkingOn ? maxIterations + 5 : maxIterations;
inputs.put(MAX_ITERATIONS, effectiveMaxIterations);
inputs.put(CURRENT_ITERATION, 0);
// 初始化新字段
inputs.put(TOOL_CALL_COUNT, 0);

View File

@ -183,22 +183,7 @@ public class ReasoningNode implements NodeAction {
log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}",
ThinkingLevelHolder.get(), effectiveReasoning, this.reasoningEffort);
ChatOptions options;
if (StringUtils.hasText(effectiveReasoning)) {
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolCallbacks)
.reasoningEffort(effectiveReasoning)
.maxTokens(maxOutputTokens)
.build();
oaiOpts.setInternalToolExecutionEnabled(false);
options = oaiOpts;
} else {
options = ToolCallingChatOptions.builder()
.toolCallbacks(toolCallbacks)
.internalToolExecutionEnabled(false)
.maxTokens(maxOutputTokens)
.build();
}
ChatOptions options = buildChatOptions(effectiveReasoning);
Prompt prompt = new Prompt(promptMessages, options);
@ -380,6 +365,62 @@ public class ReasoningNode implements NodeAction {
streamTracker.broadcastObject(conversationId, "phase", GraphEventPublisher.phase(phase, extra).data());
}
/**
* 根据 ChatModel 类型构建合适的 ChatOptions
* - AnthropicChatModel AnthropicChatOptions支持 extended thinking
* - 其他OpenAI/DashScope OpenAiChatOptions支持 reasoningEffort
*/
private ChatOptions buildChatOptions(String effectiveReasoning) {
// Anthropic 协议模型AnthropicChatModelMiniMax 也用此协议但不支持 thinking
if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) {
org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder =
org.springframework.ai.anthropic.AnthropicChatOptions.builder()
.toolCallbacks(toolCallbacks)
.internalToolExecutionEnabled(false);
// 仅对真正的 Claude 模型启用 extended thinkingMiniMax 等走 Anthropic 协议但不支持
String thinkingLevel = ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
String currentModel = getAnthropicModelName(anthropicModel);
boolean isClaudeModel = currentModel != null && currentModel.toLowerCase().contains("claude");
if (thinkingOn && isClaudeModel) {
int budgetTokens = switch (thinkingLevel.toLowerCase()) {
case "low" -> 4096;
case "medium" -> 8192;
case "high" -> 16384;
case "max" -> 32768;
default -> 16384;
};
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
builder.maxTokens(budgetTokens + maxOutputTokens);
builder.temperature(1.0);
log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens);
} else {
builder.maxTokens(maxOutputTokens);
if (thinkingOn && !isClaudeModel) {
log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel);
}
}
return builder.build();
}
// OpenAI / DashScope / 其他
// 始终使用 OpenAiChatOptions而非 ToolCallingChatOptions
// 因为 ToolCallingChatOptions 会丢失 OpenAI 特有参数streamUsage
// 导致 Kimi OpenAI 兼容 API 响应异常或提前截断
OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder()
.toolCallbacks(toolCallbacks)
.maxTokens(maxOutputTokens);
if (StringUtils.hasText(effectiveReasoning)) {
oaiBuilder.reasoningEffort(effectiveReasoning);
}
OpenAiChatOptions oaiOpts = oaiBuilder.build();
oaiOpts.setInternalToolExecutionEnabled(false);
oaiOpts.setStreamUsage(true);
return oaiOpts;
}
/**
* 解析有效的 reasoningEffort
* 优先级ThinkingLevelHolder请求级 > 构造时的 reasoningEffortAgent/模型默认
@ -403,4 +444,20 @@ public class ReasoningNode implements NodeAction {
// 无请求级覆盖使用构造时的默认值
return this.reasoningEffort;
}
/**
* AnthropicChatModel defaultOptions 中提取模型名称
* 用于判断是否为真正的 Claude 模型vs MiniMax 等走 Anthropic 协议的非 Claude 模型
*/
private String getAnthropicModelName(org.springframework.ai.anthropic.AnthropicChatModel model) {
try {
var options = model.getDefaultOptions();
if (options instanceof org.springframework.ai.anthropic.AnthropicChatOptions aOpts) {
return aOpts.getModel();
}
} catch (Exception e) {
log.debug("[ReasoningNode] Failed to extract Anthropic model name: {}", e.getMessage());
}
return null;
}
}

View File

@ -362,9 +362,18 @@ const showThinkingPanel = computed(() => !!thinkingContent.value)
//
const thinkingDuration = computed(() => {
if (isGenerating.value) return ''
if (!thinkingContent.value) return ''
// 使 segment
const segs = (props.message as any).segments || []
const thinkSeg = segs.find((s: any) => s.type === 'thinking')
const contentSeg = segs.find((s: any) => s.type === 'content')
if (thinkSeg?.timestamp && contentSeg?.timestamp) {
const sec = Math.max(1, Math.round((contentSeg.timestamp - thinkSeg.timestamp) / 1000))
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
}
// 退
const len = thinkingContent.value.length
if (len < 50) return ''
// 100 1
const sec = Math.max(1, Math.round(len / 100))
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
})

View File

@ -286,21 +286,18 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (streamPhase.value !== 'summarizing_observations') {
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
}
// 分段:追加到当前 thinking segment 或创建新的
// 分段:所有 thinking 合并到一个 segment不因 tool_call 中断而创建多个)
const segs = currentSegments.value
let thinkSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running')
// 优先复用已有的 thinking segment无论 running 还是 completed
let thinkSeg = segs.find((s: MessageSegment) => s.type === 'thinking')
if (!thinkSeg) {
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
// 修复:如果前面已有 content segment模型先发 content 后发 thinking
// 把 thinking 插入到第一个 content 之前,确保 thinking 在上方显示
const firstContentIdx = segs.findIndex((s: MessageSegment) => s.type === 'content')
if (firstContentIdx >= 0 && !segs.some((s: MessageSegment) => s.type === 'tool_call')) {
segs.splice(firstContentIdx, 0, thinkSeg)
} else {
segs.push(thinkSeg)
}
// 插入到开头thinking 始终在最上方)
segs.unshift(thinkSeg)
flushSegmentsToMessage()
}
// 新的 thinking 到来,重新设为 running
thinkSeg.status = 'running'
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
}
})