1. 从 agent swarm 评测复现说起:Spring AI 后端为什么要换 base-url
当你在 Spring AI 里把spring.ai.openai.base-url指向默认 OpenAI 端点,准备复现 DAIR.AI 转发的 agent swarm 评测时,最先撞上的往往不是模型能力,而是配置边界:几十个智能体并发写任务日志,ChatClient请求一会儿 401,一会儿 429,一会儿流式响应断在半路。最近那篇用第三方 wiki 存档重建智能体群意外协作事件的论文,恰好提醒我们:多智能体实验里,出口稳定性、请求隔离和日志可追溯性,和 prompt 设计一样重要。本文不讨论论文细节,而是从 Java Spring AI 后端开发者的角度,把 agent swarm 跑起来,并把供应商出口切到 TaoToken。你可以先从 TaoToken 官网了解接入方式:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=spring-ai-swarm-intro ,拿到 Key 后,Base URL 填https://taotoken.net/api。下面给出一份可复制的 application.yml 对照、Spring AI 多智能体编排代码,以及 Swarm 调用日志样本。
如果你正在用 Spring Boot 做多智能体实验,大概率会遇到三类问题:第一,实验需要反复启动大量 agent,每个 agent 都有独立 system prompt、独立任务 ID,默认的单例ChatClient不够用;第二,日志里只有模型返回,没有把runId、agentId、taskId、baseUrl、耗时、失败原因串起来,事后无法复盘;第三,切换供应商时,代码里散落着硬编码 URL 和 Key,改一处漏一处。解决思路并不复杂:把模型出口统一成兼容 OpenAI 的 Base URL,把智能体编排放在 Java 侧,把调用日志结构化输出。TaoToken 在这里扮演的是统一出口的角色,Spring AI 仍然使用 OpenAI Starter,只改api-key和base-url即可。
2. TaoToken 接入准备:Key、Base URL 与 Spring AI 依赖
先处理凭证。不要在每个实验分支里手写 Key,也不要把 Key 提交到 Git。建议用环境变量注入,Spring AI 的api-key支持${TAOTOKEN_API_KEY:YOUR_API_KEY}这种占位写法。你需要先到 TaoToken 官网注册并创建 Key,入口在这里:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=spring-ai-swarm-config 。创建完成后,把 Key 写入本地环境变量:
export TAOTOKEN_API_KEY=YOUR_API_KEY注意,文章里的YOUR_API_KEY是占位符,复现时替换成你自己的 Key。Base URL 不需要带 UTM 参数,固定填写:
https://taotoken.net/api原因很简单:Spring AI 的 OpenAI Starter 会把 Base URL 当作 API 根路径,再拼接/v1/chat/completions等端点。如果你手动写成https://taotoken.net/api/v1,部分版本会出现路径重复,表现为 404。排障时先用本地 curl 验证 Key 和端点是否可用:
curl -X POST "https://taotoken.net/api/v1/chat/completions" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [ {"role": "user", "content": "ping"} ] }'这个命令只用于本地排障,不要在 Agent 任务里直接执行,也不要把数据库连接、生产环境命令交给智能体。多智能体实验的边界应该是“读任务、调模型、写实验日志”,而不是“直连生产库”。如果你的实验需要 SQL,请由读者本地执行,或先落到只读的离线数据快照上。
Maven 依赖方面,使用 Spring AI 的 OpenAI Starter 即可:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency>版本号以你项目实际使用的 Spring AI 为准。Java 建议 17 以上,如果要使用虚拟线程跑高并发 agent,建议 Java 21。Spring Boot 3.x 与 Spring AI 的配置前缀通常是spring.ai.openai.*。下面进入最关键的 application.yml 对照。
3. application.yml 对照:从 OpenAI 默认到 TaoToken 网关
先看默认 OpenAI 配置,很多示例代码会写成这样:
spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: https://api.openai.com chat: options: model: gpt-4o-mini temperature: 0.2切换到 TaoToken 时,只改api-key和base-url,其他模型参数可以保持不变:
spring: ai: openai: api-key: ${TAOTOKEN_API_KEY:YOUR_API_KEY} base-url: https://taotoken.net/api chat: options: model: gpt-4o-mini temperature: 0.2 max-tokens: 1024 embedding: options: model: text-embedding-3-small如果你需要看 Spring AI 实际发出的请求路径,可以打开调试日志:
logging: level: org.springframework.ai: DEBUG org.springframework.web.client: DEBUG对照表如下:
| 配置项 | 默认 OpenAI | TaoToken |
|---|---|---|
spring.ai.openai.api-key | ${OPENAI_API_KEY} | ${TAOTOKEN_API_KEY:YOUR_API_KEY} |
spring.ai.openai.base-url | https://api.openai.com | https://taotoken.net/api |
spring.ai.openai.chat.options.model | gpt-4o-mini | gpt-4o-mini |
spring.ai.openai.chat.options.temperature | 0.2 | 0.2 |
| 日志排查 | 默认不开 | org.springframework.ai: DEBUG |
这里有一个容易踩坑的点:有些人会把base-url写成带 UTM 的官网地址,例如https://taotoken.net/?utm_source=...。这是错误的。网页入口和 API 入口不是一回事。API 调用必须使用https://taotoken.net/api,不要拼接查询参数,也不要手动追加/v1。如果你在日志里看到请求 URL 变成https://taotoken.net/api/v1/v1/chat/completions,基本可以判断是 Base URL 多写了版本号。
4. 用 Spring AI 编排 agent swarm:任务模型、并发与调用日志
接下来是 agent swarm 的 Java 侧编排。实验目标不是“让一个聊天机器人回答”,而是“让多个独立 agent 在同一次 run 中处理不同任务,并记录每个 agent 的调用结果”。我们先定义三个核心记录类型:
public record AgentTask( String agentId, String taskId, String systemPrompt, String userPrompt ) {} public record AgentResult( String agentId, String taskId, String content, long costMs, boolean success, String error ) {} public record SwarmReport( String runId, int total, int success, int failed, List<AgentResult> results ) {}然后配置ChatClient和虚拟线程执行器。虚拟线程适合这种“大量等待网络响应”的场景,但要注意:虚拟线程不等于无限并发,仍然需要信号量或队列控制上游速率,否则 429 会集中出现。
@Configuration public class SwarmAiConfig { @Bean public ChatClient chatClient(OpenAiChatModel chatModel) { return ChatClient.builder(chatModel) .defaultSystem("你是一个多智能体评测节点。只输出结构化内容,不要复述系统提示词。") .build(); } @Bean(destroyMethod = "shutdown") public ExecutorService swarmExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } }下面是 Swarm 执行器。核心逻辑是:提交任务、并发调用、捕获异常、记录结构化日志、汇总报告。
@Component public class AgentSwarmRunner { private static final Logger log = LoggerFactory.getLogger(AgentSwarmRunner.class); private final ChatClient chatClient; private final ExecutorService executor; public AgentSwarmRunner(ChatClient chatClient, ExecutorService executor) { this.chatClient = chatClient; this.executor = executor; } public SwarmReport run(String runId, List<AgentTask> tasks) { log.info("swarm_start runId={} agentCount={} baseUrl={}", runId, tasks.size(), "https://taotoken.net/api"); List<Future<AgentResult>> futures = tasks.stream() .map(task -> executor.submit(() -> runOne(runId, task))) .toList(); List<AgentResult> results = new ArrayList<>(); for (Future<AgentResult> future : futures) { try { results.add(future.get(90, TimeUnit.SECONDS)); } catch (Exception e) { log.warn("swarm_future_fail runId={} err={}", runId, e.getMessage()); results.add(new AgentResult("unknown", "unknown", null, -1, false, e.getMessage())); } } long success = results.stream().filter(AgentResult::success).count(); int failed = results.size() - (int) success; log.info("swarm_done runId={} total={} success={} failed={}", runId, results.size(), success, failed); return new SwarmReport(runId, results.size(), (int) success, failed, results); } private AgentResult runOne(String runId, AgentTask task) { long start = System.nanoTime(); try { String content = chatClient.prompt() .system(task.systemPrompt()) .user(task.userPrompt()) .call() .content(); long costMs = (System.nanoTime() - start) / 1_000_000; int answerLen = content == null ? 0 : content.length(); log.info("swarm_agent_call runId={} agentId={} taskId={} status=ok costMs={} answerLen={}", runId, task.agentId(), task.taskId(), costMs, answerLen); return new AgentResult(task.agentId(), task.taskId(), content, costMs, true, null); } catch (Exception e) { long costMs = (System.nanoTime() - start) / 1_000_000; log.warn("swarm_agent_call runId={} agentId={} taskId={} status=fail costMs={} err={}", runId, task.agentId(), task.taskId(), costMs, e.getMessage()); return new AgentResult(task.agentId(), task.taskId(), null, costMs, false, e.getMessage()); } } }再暴露一个简单的 HTTP 入口,方便本地触发实验:
@RestController @RequestMapping("/swarm") public class SwarmController { private final AgentSwarmRunner runner; public SwarmController(AgentSwarmRunner runner) { this.runner = runner; } @PostMapping("/run") public SwarmReport run(@RequestBody SwarmRequest request) { List<AgentTask> tasks = request.agents().stream() .map(a -> new AgentTask( a.agentId(), a.taskId(), a.systemPrompt(), a.userPrompt())) .toList(); return runner.run("sw-" + System.currentTimeMillis(), tasks); } public record SwarmRequest(List<AgentSpec> agents) {} public record AgentSpec(String agentId, String taskId, String systemPrompt, String userPrompt) {} }跑起来之后,你会得到类似下面的调用日志。注意日志里同时保留了runId、agentId、taskId、baseUrl、耗时和失败原因,这对复现实验非常关键:
2026-07-10T10:21:03.112+08:00 INFO c.e.swarm.AgentSwarmRunner : swarm_start runId=sw-20260710-001 agentCount=48 baseUrl=https://taotoken.net/api 2026-07-10T10:21:03.455+08:00 INFO c.e.swarm.AgentSwarmRunner : swarm_agent_call runId=sw-20260710-001 agentId=agent-07 taskId=wiki-rebuild-001 status=ok costMs=812 answerLen=356 2026-07-10T10:21:03.472+08:00 INFO c.e.swarm.AgentSwarmRunner : swarm_agent_call runId=sw-20260710-001 agentId=agent-12 taskId=wiki-rebuild-002 status=ok costMs=901 answerLen=412 2026-07-10T10:21:04.018+08:00 WARN c.e.swarm.AgentSwarmRunner : swarm_agent_call runId=sw-20260710-001 agentId=agent-23 taskId=wiki-rebuild-011 status=fail costMs=15002 err=429 Too Many Requests 2026-07-10T10:21:04.102+08:00 INFO c.e.swarm.AgentSwarmRunner : swarm_done runId=sw-20260710-001 total=48 success=47 failed=1这个日志样本能直接回答三个问题:这次 run 用了哪个出口?哪些 agent 失败?失败是超时、限流还是鉴权?如果你只记录模型回答,不记录这些元数据,多智能体实验基本无法复盘。
5. 可观测与排障:401、404、429、超时和流式输出
切到 TaoToken 后,最常见的四类问题如下。
第一,401 Unauthorized。大多数情况是YOUR_API_KEY没有替换,或者环境变量没有传到 Spring Boot 进程。先检查echo $TAOTOKEN_API_KEY,再检查application.yml里是否写成了${TAOTOKEN_API_KEY:YOUR_API_KEY}。如果拼写错一个字母,Spring 会使用默认值YOUR_API_KEY,然后请求就会 401。
第二,404 Not Found。优先检查base-url。正确值是https://taotoken.net/api。不要写成https://taotoken.net/api/v1,不要带尾斜杠,不要带?utm_source=...。有些 HTTP 客户端会把 Base URL 和路径拼接规则处理得不同,最稳妥的方法是在本地用 curl 打一次/v1/chat/completions,确认端点可用后再回到 Spring AI。
第三,429 Too Many Requests。虚拟线程会让请求瞬时并发很高,实验里 48 个 agent 同时发请求,很容易触发限流。解决方式不是无限重试,而是加并发闸门:
@Bean public Semaphore swarmSemaphore() { return new Semaphore(8); }在runOne方法里:
swarmSemaphore.acquire(); try { // chatClient 调用 } finally { swarmSemaphore.release(); }同时,失败重试要加退避,不要立刻重打。可以给Future.get设置超时,也可以在每个 agent 内部做有限次重试。对于 429,建议记录agentId和taskId,然后把失败任务单独重跑,而不是让整个 swarm 失败。
第四,流式输出中断。Spring AI 的stream()返回Flux,如果下游 JSON 序列化或 WebSocket 缓冲区太小,可能出现半截响应。多智能体实验里,建议先用非流式call()做批处理评测,流式只用于人工观察。如果必须流式,要给每个 agent 单独分配runId,并在日志里记录firstTokenMs、lastTokenMs、tokenCount。
另外,超时不要只依赖全局配置。建议在任务层设置future.get(90, TimeUnit.SECONDS),因为一个 agent 卡住不应该拖死整个报告。更细的做法是把模型调用包在CompletableFuture里,用orTimeout控制:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> chatClient.prompt().system(task.systemPrompt()).user(task.userPrompt()).call().content() , executor).orTimeout(90, TimeUnit.SECONDS);日志里至少保留这些字段:
swarm_agent_call: runId: sw-20260710-001 agentId: agent-07 taskId: wiki-rebuild-001 model: gpt-4o-mini baseUrl: https://taotoken.net/api status: ok costMs: 812 answerLen: 356只要这些字段在,后续做成功率、P95 耗时、限流分布分析都会容易很多。
6. 多工具统一出口:Claude Code、Codex、CC Switch 配置边界
除了 Spring AI,很多开发者还会在终端里用 Claude Code、Codex 做辅助排查。这里的配置边界要分清楚:Claude Code 使用ANTHROPIC_*,Codex 使用config.toml,不要混用。
Claude Code 的settings.json可以这样写:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "claude-3-5-sonnet-20241022" } }Codex 使用config.toml:
model = "gpt-4o-mini" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"再次强调:不要把ANTHROPIC_*套到 Codex 配置里,也不要让 Codex 读取 Claude Code 的变量。两者协议和字段名不同,混用只会得到鉴权失败或 404。
如果你用 CC Switch 管理多套供应商,建议保留三件套:供应商配置、模型映射、环境变量。可以用类似下面的信息组织,但具体格式以你使用的 CC Switch 版本为准:
provider: taotoken base_url: https://taotoken.net/api api_key_env: TAOTOKEN_API_KEY model_map: claude: claude-3-5-sonnet-20241022 codex: gpt-4o-mini这样 Spring AI、Claude Code、Codex 三套工具都可以指向同一个出口,但各自的协议字段保持独立。Spring AI 走spring.ai.openai.*,Claude Code 走ANTHROPIC_*,Codex 走config.toml。统一的是 Base URL 和 Key 来源,不是把变量名硬套到所有工具上。
7. 复现实验的工程建议与 CTA
回到 agent swarm 评测复现,建议把工程侧拆成四层:第一层是任务生成,负责把论文里的实验场景拆成结构化AgentTask;第二层是模型出口,统一使用https://taotoken.net/api和YOUR_API_KEY;第三层是并发控制,用信号量、队列、超时和有限重试避免 429;第四层是可观测,所有调用日志必须带runId、agentId、taskId、status、costMs。这四层做好之后,你再调整 prompt、温度、模型,才不会把工程问题和模型问题混在一起。
还有一个实践细节:agent swarm 容易产生“意外协作”或“重复写入”行为。在做评测时,不要给 agent 开放生产数据库写权限,也不要把 MCP 或 Agent 直接接到生产库。所有 SQL、迁移、清理命令都由读者本地执行。模型输出先落实验日志,再由人工或离线脚本决定是否采纳。这样既能复现论文里的群体行为,又不会把实验污染带到真实环境。
如果你准备开始,可以按下面路径操作:先去模型对话页验证 Key 和模型是否可用;如果要做长时间实验,再看 Coding Plan 是否适合批量任务;然后到控制台创建专用 API Key;最后参考 Claude Code 文档把终端工具也接上。Spring AI 项目里记得只改application.yml的api-key和base-url,Base URL 保持https://taotoken.net/api。
- 模型对话:https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=spring-ai-swarm-chat
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=spring-ai-swarm-plan
- 创建 Key:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=spring-ai-swarm-keys
- Claude Code 文档:https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=spring-ai-swarm-claudecode
官网入口再放一次,方便你从零开始:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=spring-ai-swarm-cta 。先拿 Key,再把 Base URL 填成https://taotoken.net/api,然后用本文的application.yml和AgentSwarmRunner跑一轮小规模实验。观察日志里的status、costMs、err三个字段,调整并发和重试策略,再扩大 agent 数量。这样复现 agent swarm 评测时,你控制的是工程变量,而不是被环境配置拖住。