1. 从一次 JSON 解析失败说起:工具调用链路到底难在哪
如果你正在做 Agent 或者工具调用相关的开发,大概率遇到过这种场景:模型明明该返回一个干净的 JSON,结果它先给你来一段“好的,我来分析一下”,再吐两个重复的 JSON 对象,最后json.loads直接抛Extra data。这不是模型不行,而是工具调用链路缺少统一约束。
大模型的工具调用能力其实经历了三层演进。第一层是function call,解决的是“模型怎么把自然语言意图翻译成结构化调用”的问题,核心是 schema 定义和参数传递。第二层是思维链 COT,解决的是“复杂任务怎么拆成可执行步骤”的问题,让模型先规划再动手。第三层是MCP 协议,解决的是“上下文怎么标准化接入、多轮对话怎么维护状态”的问题,把函数调用、对话历史、多步骤计划统一到一套消息格式里。
这三层不是替代关系,而是叠加关系。你完全可以在 MCP 的消息结构里跑 COT 的步骤分解,每一步再落到具体的 function call 上。本文要做的,就是用TaoToken 统一 Key/API 通道把这三层串起来,给你一套可复制的settings.json和config.toml配置骨架,再逐层验证 function call、COT、MCP 是否真的生效。适合正在搭 Agent、写工具调用、被 JSON 解析折磨过的开发者。
2. TaoToken 前置:统一 Key 与 API 通道怎么接
在动手写调用链路之前,先把通道打通。TaoToken 在这里的角色是统一的模型接入层:你不需要为每个模型单独维护一套 Key 和 endpoint,用一个 Key 就能切换不同模型,这对调试工具调用特别重要——因为不同模型对 JSON 格式的遵循程度不一样,你需要快速对比。
先拿到 API Key。访问控制台创建:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite创建完 Key 之后,基础接入信息如下:
| 项目 | 值 |
|---|---|
| API Base | https://taotoken.net/api |
| 鉴权方式 | Authorization: Bearer <你的Key> |
| 对话接口 | /v1/chat/completions |
| 模型列表 | /v1/models |
这里有个坑要提前说:API Base 不要加 UTM 参数,只有官网和控制台链接才带。很多人在配置里把带参数的完整 URL 填进去,结果请求 404。正确的做法是 base 只填https://taotoken.net/api,路径在代码里拼。
如果你用的是 Claude Code 这类编码工具,或者要跑长期的 Agent 任务,建议直接看 Coding Plan,它把额度和通道都打包好了:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite接入文档在这里,遇到参数问题先查它:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite3. 可复制配置:settings.json 与 config.toml 骨架
配置分两种场景。一种是给编辑器/编码工具用的settings.json,一种是给 Python 项目或 CLI 用的config.toml。两个都给你,按需取用。
3.1 settings.json:编辑器与工具链接入
这个文件适合放在项目根目录或者工具的配置目录下。核心是把 base URL、Key、模型名分开管理,方便切换。
{ "provider": "taotoken", "api_base": "https://taotoken.net/api", "api_key_env": "TAOTOKEN_API_KEY", "default_model": "claude-sonnet-4-20250514", "timeout_seconds": 60, "max_retries": 2, "tool_calling": { "enabled": true, "strict_json": true, "max_tool_rounds": 5, "parallel_tool_calls": false }, "logging": { "level": "INFO", "file": "function_call.log", "log_raw_response": true } }几个参数值得单独说。strict_json打开后,会在提示词里强制约束模型只输出 JSON,这是解决Extra data报错的第一道防线。max_tool_rounds限制工具调用的最大轮数,防止 COT 递归调用时无限循环。log_raw_response一定要开,调试工具调用时,原始响应比解析后的结果更有价值。
3.2 config.toml:Python 项目与 CLI 接入
如果你用 Python 直接调,或者用支持 TOML 的 CLI 工具,用这份:
[provider] name = "taotoken" api_base = "https://taotoken.net/api" api_key = "${TAOTOKEN_API_KEY}" default_model = "claude-sonnet-4-20250514" [request] timeout = 60 max_retries = 2 temperature = 0.7 max_tokens = 1024 [tool_calling] enabled = true strict_json = true max_tool_rounds = 5 [cot] enabled = true max_steps = 6 require_step_description = true [mcp] enabled = true message_roles = ["system", "user", "assistant", "function"] keep_history = true[cot]段控制思维链的行为,max_steps防止模型把简单任务拆成十几步。[mcp]段定义消息角色,这是 MCP 协议标准化的关键——所有交互都通过messages列表传递,而不是拼字符串。
环境变量这样设:
export TAOTOKEN_API_KEY="sk-你的Key"4. 逐层验证:function call、COT、MCP 是否真的生效
配置只是骨架,真正要确认的是三层能力有没有跑通。下面按 function call → COT → MCP 的顺序,每层给一个可执行的验证动作。
4.1 第一层:验证 function call 是否生效
先定义一个最简单的工具 schema,然后发一个明确需要调用工具的请求。关键观察点是:模型返回里有没有结构化的tool_calls字段,参数对不对。
import os import json from openai import OpenAI client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url="https://taotoken.net/api" ) tools = [ { "type": "function", "function": { "name": "search_product", "description": "根据名称在数据库中搜索产品", "parameters": { "type": "object", "properties": { "product_name": { "type": "string", "description": "要搜索的产品名称" } }, "required": ["product_name"] } } } ] resp = client.chat.completions.create( model="claude-sonnet-4-20250514", messages=[ {"role": "user", "content": "帮我查一下 iPhone 14 的信息"} ], tools=tools, tool_choice="auto" ) msg = resp.choices[0].message print("finish_reason:", resp.choices[0].finish_reason) print("tool_calls:", msg.tool_calls)如果 function call 生效,你会看到finish_reason是tool_calls,并且msg.tool_calls里有search_product和{"product_name": "iPhone 14"}。如果finish_reason是stop,说明模型选择直接回答,没走工具——这时候检查你的tool_choice和提示词,或者换个对工具调用支持更好的模型。
拿到tool_calls之后,执行本地函数,把结果以role: "tool"塞回消息列表,再发一次请求:
tool_call = msg.tool_calls[0] args = json.loads(tool_call.function.arguments) result = search_product_in_db(args["product_name"]) messages = [ {"role": "user", "content": "帮我查一下 iPhone 14 的信息"}, msg, { "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) } ] final = client.chat.completions.create( model="claude-sonnet-4-20250514", messages=messages, tools=tools ) print(final.choices[0].message.content)这一步跑通,说明 function call 的完整闭环没问题:模型决策 → 参数提取 → 本地执行 → 结果回填 → 最终回答。
4.2 第二层:验证 COT 是否真的在拆步骤
COT 的验证不能只看最终答案,要看中间步骤。做法是加一个reason_step_by_step工具,让模型把复杂查询拆成步骤计划,然后你检查返回的steps数组。
tools.append({ "type": "function", "function": { "name": "reason_step_by_step", "description": "将复杂查询分解为多个推理步骤,可调用其他函数", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "要分析的用户查询" } }, "required": ["query"] } } }) resp = client.chat.completions.create( model="claude-sonnet-4-20250514", messages=[ {"role": "user", "content": "对比 iPhone 14 和 Galaxy S23"} ], tools=tools, tool_choice="auto" ) msg = resp.choices[0].message if msg.tool_calls: for tc in msg.tool_calls: print(tc.function.name, tc.function.arguments)如果 COT 生效,模型应该调用reason_step_by_step,参数里带上完整查询。然后你在本地实现这个函数时,让它返回一个步骤计划,比如:
{ "steps": [ {"description": "查询 iPhone 14 信息", "function_call": {"name": "search_product", "arguments": {"product_name": "iPhone 14"}}}, {"description": "查询 Galaxy S23 信息", "function_call": {"name": "search_product", "arguments": {"product_name": "Galaxy S23"}}}, {"description": "对比两者参数", "function_call": null} ] }验证要点:步骤数量是否合理(别超过max_steps)、每步的function_call是否指向真实存在的工具、最后一步是不是汇总而非继续调用。如果模型把简单查询也拆成五步,说明提示词约束不够,加一句“简单查询直接回答,不要拆步骤”。
4.3 第三层:验证 MCP 消息结构是否标准化
MCP 的核心是用 messages 列表维护完整上下文,而不是每次拼新字符串。验证方法是跑一个多轮任务,检查对话历史里 role 是否规范、函数结果有没有正确回填。
messages = [ {"role": "system", "content": "你是一个支持工具调用的助手。"}, {"role": "user", "content": "对比 iPhone 14 和 Galaxy S23"} ] for round_idx in range(5): resp = client.chat.completions.create( model="claude-sonnet-4-20250514", messages=messages, tools=tools ) msg = resp.choices[0].message messages.append(msg) if not msg.tool_calls: print("最终回答:", msg.content) break for tc in msg.tool_calls: fn = tc.function.name args = json.loads(tc.function.arguments) print(f"[round {round_idx}] 调用 {fn} 参数 {args}") if fn == "search_product": result = search_product_in_db(args["product_name"]) elif fn == "reason_step_by_step": result = {"steps": [...]} else: result = {"error": "unknown function"} messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False) })跑完之后打印messages,检查三件事:role是否只有system/user/assistant/tool四种、每个tool消息有没有对应的tool_call_id、assistant消息里的tool_calls和后续tool结果是否一一对应。这三条都满足,MCP 的消息结构就算标准化了。
5. 本篇常见错排查
工具调用链路的报错集中在几个地方,按出现频率排一下。
JSON 解析报Extra data。这是最常见的。模型在 JSON 后面又跟了一段解释,或者返回了两个 JSON 对象。解决分两步:提示词里明确加“只输出一个 JSON 对象,不要包含任何解释或重复内容”;解析时用re.findall取最后一个完整 JSON,而不是re.search取第一个。取最后一个的原因是,模型有时会先输出一个草稿再输出正式版,最后一个通常才是完整的。
Invalid response structure。模型返回的 JSON 里没有role和content,而是直接给了{"name": ..., "arguments": ...}。这是格式兼容问题,加一层转换:如果检测到name和arguments字段,就包装成标准的 function call 结构。别指望模型每次都严格遵循格式,代码要能兜底。
工具调用死循环。COT 递归调用时,模型可能反复调用reason_step_by_step而不收敛。两个措施:设置max_tool_rounds硬上限;在reason_step_by_step的返回里明确告诉模型“这是最后一步,请直接汇总”。
参数类型不对。模型有时把arguments返回成字符串而不是对象,json.loads一下就好。但要注意,如果字符串本身不是合法 JSON,就得走修复逻辑:单引号转双引号、补全缺失的右括号。
模型不调用工具直接回答。检查tool_choice是不是auto,检查工具描述是否清晰,检查用户 query 是否真的需要工具。有时候是模型判断不需要工具,这时候别硬逼,换个更明确的 query 再测。
请求 404 或 401。404 通常是 base URL 拼错了,确认是https://taotoken.net/api加上/v1/chat/completions。401 是 Key 问题,去控制台重新生成一个:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite6. 把三层串起来:一个可调试的调用链路
回到最开始的问题:怎么让 function call、COT、MCP 协同工作,而不是各管各的。我的做法是用 MCP 的消息结构做容器,用 COT 做规划层,用 function call 做执行层。
具体流程是这样:用户 query 进来,先走reason_step_by_step让模型拆步骤,返回一个steps数组。然后遍历每个 step,如果 step 里有function_call,就执行对应的本地函数,把结果以role: "tool"回填到 messages。所有步骤执行完,再发一次请求让模型汇总。整个过程 messages 列表始终维护着完整上下文,这就是 MCP 标准化的价值。
调试的时候,把log_raw_response打开,每次请求的原始响应都记下来。对比“模型返回了什么”和“你解析出了什么”,大部分问题一眼就能定位。如果模型返回的格式总是不稳定,换个模型试试——不同模型对工具调用的遵循度差异很大,这也是用 TaoToken 统一通道的好处,切换成本低。
想直接看模型对话效果,可以在这里试:
https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite长期跑编码或 Agent 任务,Coding Plan 更划算:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite接入细节和参数说明都在文档里:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite最后给一个实用建议:先把 function call 单独跑通,再加 COT,最后套 MCP 的消息结构。三层一起上,出问题你根本不知道是哪层的锅。逐层验证,每层留好日志,链路自然就稳了。