从一次@ToolParam缺 example 的排障说起
在 Spring AI 的 MCP Server 里,@ToolParam(description = ..., required = true)只能生成description和required,JSON Schema 里没有examples、没有default。模型拿到orderDetail、getUserInfo、getWeather这类工具定义时,只能靠描述猜参数格式,region该填“上海”还是“上海市”、date该填2024-01-01还是"今天",全靠运气。更隐蔽的坑是:当方法参数上完全没有@ToolParam注解时,ExtendedJsonSchemaGenerator.addExampleAndDefaultValue会在提前return的分支里直接结束,嵌套对象WeatherQueryParam的字段注解根本不会被递归处理。
这篇是排障视角:不改 Spring AI 框架源码,用 Codex 走 TaoToken 通道消耗 Token,对照ExtendedJsonSchemaGenerator的提前 return 分支,把“参数无注解时仍递归处理嵌套对象字段”的逻辑补上,最后跑本地getWeather示例验证region/date是否出现examples和default。TaoToken 在这里只负责给 Codex 提供 Key 和 Base URL,不参与 schema 生成。需要 Key 就从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 进入控制台创建。
TaoToken 前置:给 Codex 一条可对照的通道
排障的关键不是“让模型帮我写代码”,而是让 Codex 能稳定地读到ExtendedJsonSchemaGenerator的源码上下文、反复对照addExampleAndDefaultValue的分支走向,并在我改完后立刻用同一套工具定义去验证 schema 输出。这需要一条可控的模型通道。
TaoToken 的定位很明确:提供 API Key 和 Base URL,让 Codex 这类编码工具走统一入口消耗 Token。它不生成 schema、不替代 Spring AI、也不接管你的注解解析逻辑。你把它当成 Codex 的“模型出口”即可。
操作顺序:
- 打开 https://taotoken.net/?utm_source=taotoken_aicg_blog_end ,注册并进入控制台。
- 在控制台创建 API Key,得到形如
YOUR_API_KEY的凭证。 - 记下 Base URL:
https://taotoken.net/api。注意不要带/v1,也不要加 UTM 参数,Codex 侧只认这个干净地址。 - 如果你后续要长期跑编码 Agent,可以在控制台看 Coding Plan;只是本次排障,用按量 Token 即可。
Key 创建入口在控制台的 API Keys 页面,接入细节可对照接入文档。这两处是排障时最常回看的地方:Key 是否复制完整、Base URL 是否被误加了/v1。
可复制配置:Codex 侧接 TaoToken
Codex 的配置走config.toml。下面这段可以直接复制,把YOUR_API_KEY换成你刚创建的值:
# ~/.codex/config.toml model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" [profiles.default] model_provider = "taotoken" model = "claude-sonnet-4-5"然后在 shell 里导出环境变量,避免 Key 写进配置文件:
export TAOTOKEN_API_KEY="YOUR_API_KEY"如果你用的是 Claude Code 而不是 Codex,配置位置换成settings.json,字段是ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "YOUR_API_KEY" } }两个注意点,都是排障时踩过的:
- Base URL 只写
https://taotoken.net/api,不要写成https://taotoken.net/api/v1。多一段路径会导致请求 404,而报错信息往往只显示“模型不可用”,容易误判成 Key 问题。 - 不要把 UTM 参数拼进 Base URL。UTM 只用于官网跳转统计,API 地址保持干净。
配置完成后,Codex 就能在项目里读取ExtendedJsonSchemaGenerator.java,对照addExampleAndDefaultValue的分支做修改。
对照ExtendedJsonSchemaGenerator改提前 return 分支
先还原问题现场。原始addExampleAndDefaultValue的逻辑大致是这样:
private static void addExampleAndDefaultValue(ObjectNode parameterNode, Method method, int parameterIndex, Type parameterType) { Parameter parameter = method.getParameters()[parameterIndex]; ExtendedToolParam extendedAnnotation = parameter.getAnnotation(ExtendedToolParam.class); if (extendedAnnotation != null) { addExample(parameterNode, extendedAnnotation.example()); addDefaultValue(parameterNode, extendedAnnotation.defaultValue()); if (parameterType instanceof Class<?>) { addExampleAndDefaultFromClassFields(parameterNode, (Class<?>) parameterType); } return; } ToolParam toolParamAnnotation = parameter.getAnnotation(ToolParam.class); if (toolParamAnnotation != null) { if (parameterType instanceof Class<?>) { addExampleAndDefaultFromClassFields(parameterNode, (Class<?>) parameterType); } return; // ← 坑就在这里 } // 参数上没有任何注解时,方法直接走到结尾,嵌套对象字段不会被处理 }问题出在第二个return。当参数只有@ToolParam时,代码处理完嵌套对象就返回了;而当参数上一个注解都没有时,方法既没有进入任何分支,也没有兜底逻辑,WeatherQueryParam里的region、date字段注解就被完全跳过。表现就是:getWeather(WeatherQueryParam param)生成的 schema 里,param.properties.region只有description,没有examples和default。
修复思路是补一个兜底分支:参数无@ExtendedToolParam、无@ToolParam时,只要参数类型是复杂对象(非基本类型、非 String、非 Number、非 Boolean、非枚举),仍然递归处理它的字段。
ToolParam toolParamAnnotation = parameter.getAnnotation(ToolParam.class); if (toolParamAnnotation != null) { if (parameterType instanceof Class<?>) { addExampleAndDefaultFromClassFields(parameterNode, (Class<?>) parameterType); } return; } // 兜底:参数无任何注解时,仍递归处理嵌套对象字段 if (parameterType instanceof Class<?>) { Class<?> clazz = (Class<?>) parameterType; if (!clazz.isPrimitive() && clazz != String.class && !Number.class.isAssignableFrom(clazz) && clazz != Boolean.class && !clazz.isEnum()) { addExampleAndDefaultFromClassFields(parameterNode, clazz); } }这段兜底逻辑和@ToolParam分支里的递归调用是同一个入口addExampleAndDefaultFromClassFields,区别只是触发条件从“有注解”放宽到“是复杂对象”。这样WeatherQueryParam即使作为裸参数传入,字段上的@ExtendedToolParam也能被读到。
改完后,addExampleAndDefaultFromClassFields内部对每个字段的处理保持不变:先取@ExtendedToolParam,有就写examples和default;字段本身还是嵌套对象时,继续递归。addExample里对 JSON 格式字符串的兼容逻辑也保留——example = "上海"和example = "\"上海\""都能正确落到examples数组。
验证请求:跑本地getWeather看 examples 和 default
改完代码后,用本地getWeather示例做验证。工具定义如下:
@Data public class WeatherQueryParam { @ExtendedToolParam(description = "地区", required = true, example = "上海", defaultValue = "北京") private String region; @ExtendedToolParam(description = "日期", required = false, example = "\"2024-01-01\"", defaultValue = "\"今天\"") private String date; } @Tool(name = "getWeather", description = "获取某个地区的天气") public String getWeather(WeatherQueryParam param) { return "今天50度"; }注意getWeather的参数param上没有@ToolParam,也没有@ExtendedToolParam。这正是修复前会漏处理的场景。
调用ExtendedJsonSchemaGenerator.generateForMethodInput后,期望输出里param.properties下应出现:
{ "param": { "type": "object", "properties": { "region": { "type": "string", "description": "地区", "examples": ["上海"], "default": "北京" }, "date": { "type": "string", "description": "日期", "examples": ["2024-01-01"], "default": "今天" } }, "required": ["region"] } }检查点有三个:
region节点是否有examples数组且值为["上海"]。region节点是否有default且值为"北京"。date节点是否同样出现examples和default,且"2024-01-01"被解析成不带转义的字符串。
如果region/date仍然只有description,说明兜底分支没生效,回到addExampleAndDefaultValue确认参数类型判断是否把WeatherQueryParam误判成了简单类型。如果examples里出现的是带引号的"\"上海\"",说明addExample的 JSON 解析分支没走到,检查OBJECT_MAPPER.readValue是否抛异常被吞掉。
验证通过后,再把orderDetail、getUserInfo这类带@ToolParam的旧工具跑一遍,确认原有行为没有被兜底分支改变——@ToolParam分支仍然优先,@ExtendedToolParam优先级最高。
本篇常见错排查
Base URL 带了/v1或 UTM。Codex 报模型不可用、404,先看config.toml里的base_url。正确值是https://taotoken.net/api,不带/v1,不带?utm_source=...。UTM 只用于官网跳转,API 地址必须干净。
Key 没导出到环境变量。config.toml里写的是env_key = "TAOTOKEN_API_KEY",如果 shell 里没有export TAOTOKEN_API_KEY="YOUR_API_KEY",Codex 启动时会拿不到凭证。用echo $TAOTOKEN_API_KEY确认非空。
兜底分支把简单类型也递归了。如果region是String,兜底条件里的clazz != String.class会拦住它,不会进入addExampleAndDefaultFromClassFields。如果发现简单类型字段被误处理,检查这几个排除条件是否写全:isPrimitive、String、Number、Boolean、isEnum。
@ToolParam分支的return被删了。修复时容易顺手把@ToolParam分支里的return也去掉,导致有@ToolParam的参数走完递归后又进兜底分支,重复处理。保留return,兜底只针对“无任何注解”的情况。
examples格式不对。example = "上海"期望输出["上海"];example = "\"2024-01-01\""期望输出["2024-01-01"]。如果输出里带多余转义,检查addExample里OBJECT_MAPPER.readValue的异常分支是否被正确触发。
改了代码但没重新生成 schema。ExtendedToolDefinitions.from每次调用都会重新走ExtendedJsonSchemaGenerator,如果验证时还是旧结果,确认调用的是扩展版ExtendedMethodToolCallbackProvider,而不是框架默认的MethodToolCallbackProvider。
语义一致:Key、接入文档与后续编码
本次排障涉及两类操作:一类是 Codex 接入 TaoToken 的配置(Key、Base URL、config.toml/settings.json),一类是 schema 生成逻辑的修改与验证。前者对应 API Keys 和接入文档,后者对应模型对话验证。
- 需要创建或更换 Key、核对 Base URL 写法、看 Codex/Claude Code 接入细节:走 API Keys 与接入文档。
- 改完
ExtendedJsonSchemaGenerator后想直接对话验证 schema 输出、对比不同example格式的解析结果:走模型对话。 - 如果你后续要把这套 MCP Server 的排障和迭代做成长期编码任务,反复用 Codex 对照源码改分支、跑验证:可以看 Coding Plan。
入口统一从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 进控制台,API 地址保持https://taotoken.net/api。TaoToken 在这里的角色始终是给 Codex 提供 Key 和 Base URL,schema 生成逻辑仍然在你的ExtendedJsonSchemaGenerator里,改的是那个提前 return 的分支,验证的是region/date的examples和default。