1. 从零拆解 Coding Agent:LLM 工具调用循环到底在做什么
很多人第一次用 Claude Code 或者 Cursor 的时候,都会有一种「这东西是不是偷偷藏了个程序员在里面」的错觉。你打一句「帮我在项目里加个登录接口」,它就能自己翻目录、读文件、改代码、跑测试,全程不用你动手。但如果你把这层壳扒开,会发现里面其实就是一个非常朴素的循环:LLM 负责决策,本地代码负责执行,执行结果再喂回给 LLM。
这个循环有个正式名字叫 Tool Use Loop,或者叫 Function Calling Loop。它的核心假设是:大语言模型本身不能碰你的文件系统、不能执行命令、不能访问网络,它唯一能做的就是输出文本。但如果我们约定一种文本格式,让模型在需要干活的时候输出「我要调用 read_file,参数是 xxx」,然后由我们的 Python 脚本去真正执行这个调用,再把结果拼回对话历史里,模型就能基于新信息继续决策。如此往复,直到它认为任务完成,输出一段普通文本作为终止信号。
我试过把这个循环拆到最简,发现一个能用的 Coding Agent 只需要三个工具:读文件、列目录、写文件。Claude Code 之所以强,是因为它在这个骨架上加了 grep 检索、bash 执行、流式输出、上下文压缩、权限确认等一大堆工程优化,但最小可运行版本真的不到 200 行 Python。这篇文章就带你从零把这个循环写出来,并且用 TaoToken 的统一 Key 把请求跑通,让你亲眼看到「魔法」是怎么一步步发生的。
适合谁看?如果你写过 Python、调过任意一家 LLM 的 API、对 Agent 这个词有点好奇但没动手实现过,那这篇就是给你准备的。读完之后你不仅能理解 Claude Code 类工具的底层原理,还能得到一个可以自己扩展的本地脚本,想加什么工具就加什么工具。
在动手之前,先把整体数据流画清楚。一次完整的工具调用会经历这些阶段:用户输入进入对话历史;脚本把历史发给 LLM;LLM 返回文本,其中可能包含tool: read_file({"filename": "app.py"})这样的指令;脚本用正则或字符串解析提取出工具名和 JSON 参数;脚本在本地执行对应函数;执行结果被包装成tool_result(...)追加到对话历史;脚本再次调用 LLM;LLM 看到结果后决定是继续调工具还是给出最终回答。这个内层循环会一直转,直到某次 LLM 回复里不再包含工具指令为止。
理解了这一点,你就理解了所有 Coding Agent 的本质。剩下的都是工程细节:怎么让解析更鲁棒、怎么处理多工具并行调用、怎么控制上下文长度、怎么做权限校验。我们先把最小版本跑起来,再谈优化。
2. TaoToken 统一 Key 前置准备:一个 Base URL 打通多模型调用
在写 Agent 之前,得先解决「用哪家模型」的问题。原版示例用的是 Anthropic 官方 SDK,但国内开发者直接调官方接口经常会遇到网络和支付上的麻烦。更实际的做法是走一个兼容 OpenAI 协议的统一通道,这样你的 Agent 代码不用绑死在某一家 SDK 上,换模型只需要改一个 Model ID。
TaoToken 就是这样一个通道。它的 API 地址是https://taotoken.net/api,兼容 OpenAI 的/v1/chat/completions接口格式。这意味着你既可以用openai这个 Python 包直接调,也可以用requests手写 HTTP 请求。对我们要写的 Agent 来说,最大的好处是:工具调用的解析逻辑完全由我们自己控制,不依赖任何 SDK 的高级封装,所以换通道的成本几乎为零。
你需要准备三样东西:Base URL、API Key、Model ID。Base URL 填https://taotoken.net/api,注意后面拼接路径时是/v1/chat/completions。API Key 在控制台的 API Keys 页面创建,建议单独建一个给这个项目用,方便后续排查和吊销。Model ID 填你账户里可用的模型名,比如claude-sonnet-4-20250514或者任意你常用的模型标识。
如果你还没创建 Key,可以先去控制台生成一个。整个流程不复杂:登录后进入 API Keys 页面,点新建,复制出来存到环境变量里。我习惯用.env文件管理,配合python-dotenv读取,这样代码里不会出现明文 Key。
# .env 文件内容 TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_MODEL=claude-sonnet-4-20250514这里有个容易踩的坑:Base URL 到底带不带/v1。OpenAI 官方 SDK 的base_url参数通常填到域名加/v1,然后 SDK 自己拼/chat/completions。但如果你用requests手写,就得自己拼完整的https://taotoken.net/api/v1/chat/completions。两种方式都对,关键是别重复拼/v1/v1。我下面给的代码用的是requests手写方式,路径写全,避免歧义。
另外提醒一句,工具调用的稳定性跟模型能力关系很大。有些小模型对 JSON 格式的遵循度不高,容易输出带注释的 JSON 或者多行 JSON,导致解析失败。建议先用一个指令遵循能力强的模型把流程跑通,再考虑换更便宜的模型做成本优化。TaoToken 的好处是同一个 Key 可以切换不同模型,你可以在.env里改一行就完成对比测试。
准备好 Key 之后,先别急着写 Agent,用一条最简单的 curl 验证通道是否通。这一步能帮你排除掉 90% 的环境问题。
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "回复两个字:通了"}], "max_tokens": 20 }'如果返回的 JSON 里有choices[0].message.content,说明通道没问题,可以进入下一步。如果报 401,检查 Key 有没有复制完整、有没有多余空格;如果报 model not found,检查 Model ID 拼写。这些排查思路在第五节还会展开。
3. 可复制配置与 Agent 主循环代码:工具 schema 定义与多轮状态管理
这一节是全文的核心,我会把完整代码拆成几块讲清楚,每一块你都可以直接复制运行。整体结构是:配置加载 → 工具函数定义 → 工具注册表 → 系统提示词 → 工具调用解析 → LLM 调用封装 → 主循环。
先看配置和依赖。你需要装两个包:requests和python-dotenv。不需要装任何 LLM 厂商的 SDK,因为我们直接走 HTTP。
# agent.py import json import os import re import inspect from pathlib import Path from typing import Any, Dict, List, Tuple import requests from dotenv import load_dotenv load_dotenv() API_KEY = os.environ["TAOTOKEN_API_KEY"] BASE_URL = os.environ.get("TAOTOKEN_BASE_URL", "https://taotoken.net/api") MODEL = os.environ.get("TAOTOKEN_MODEL", "claude-sonnet-4-20250514") CHAT_ENDPOINT = f"{BASE_URL}/v1/chat/completions"接下来是路径解析工具。所有文件操作都基于当前工作目录解析绝对路径,避免相对路径带来的混乱。
def resolve_abs_path(path_str: str) -> Path: path = Path(path_str).expanduser() if not path.is_absolute(): path = (Path.cwd() / path).resolve() return path然后是三个核心工具。注意每个函数的 docstring 要写清楚,因为我们会把它拼进系统提示词,模型靠这段描述来判断该调哪个工具、参数怎么传。
def read_file_tool(filename: str) -> Dict[str, Any]: """读取指定文件的完整内容。参数 filename 是文件路径。""" full_path = resolve_abs_path(filename) with open(str(full_path), "r", encoding="utf-8") as f: content = f.read() return {"file_path": str(full_path), "content": content} def list_files_tool(path: str) -> Dict[str, Any]: """列出指定目录下的所有文件和子目录。参数 path 是目录路径。""" full_path = resolve_abs_path(path) all_files = [] for item in full_path.iterdir(): all_files.append({ "filename": item.name, "type": "file" if item.is_file() else "dir" }) return {"path": str(full_path), "files": all_files} def edit_file_tool(path: str, old_str: str, new_str: str) -> Dict[str, Any]: """编辑文件。old_str 为空时创建新文件并写入 new_str; 否则把文件中第一次出现的 old_str 替换为 new_str。""" full_path = resolve_abs_path(path) if old_str == "": full_path.write_text(new_str, encoding="utf-8") return {"path": str(full_path), "action": "created_file"} original = full_path.read_text(encoding="utf-8") if original.find(old_str) == -1: return {"path": str(full_path), "action": "old_str not found"} edited = original.replace(old_str, new_str, 1) full_path.write_text(edited, encoding="utf-8") return {"path": str(full_path), "action": "edited"}工具注册表把名字和函数绑起来,后面解析出工具名之后直接查表调用。
TOOL_REGISTRY = { "read_file": read_file_tool, "list_files": list_files_tool, "edit_file": edit_file_tool, }系统提示词是整个 Agent 的「说明书」。它告诉模型有哪些工具、参数格式是什么、输出格式必须严格遵守。这里我用了一个明确的单行 JSON 约定,方便解析。
SYSTEM_PROMPT = """You are a coding assistant that helps solve coding tasks. You have access to the following tools: {tool_list_repr} When you want to use a tool, reply with exactly one line in this format: tool: TOOL_NAME({{"arg1": "value1", "arg2": "value2"}}) Use compact single-line JSON with double quotes. Do not add explanations on the same line. After receiving a tool_result(...) message, continue the task. If no tool is needed, respond normally with your final answer. """工具描述自动生成函数,从函数签名和 docstring 里提取信息,这样加新工具时不用手写提示词。
def get_tool_str_representation(tool_name: str) -> str: tool = TOOL_REGISTRY[tool_name] return ( f"Name: {tool_name}\n" f"Description: {tool.__doc__}\n" f"Signature: {inspect.signature(tool)}\n" ) def get_full_system_prompt() -> str: parts = [] for name in TOOL_REGISTRY: parts.append("TOOL\n===" + get_tool_str_representation(name)) parts.append("=" * 15) return SYSTEM_PROMPT.format(tool_list_repr="\n".join(parts))工具调用解析函数。模型可能在一段回复里输出多行工具指令,所以按行扫描,遇到tool:开头就尝试解析。
def extract_tool_invocations(text: str) -> List[Tuple[str, Dict[str, Any]]]: invocations = [] for raw_line in text.splitlines(): line = raw_line.strip() if not line.startswith("tool:"): continue try: after = line[len("tool:"):].strip() name, rest = after.split("(", 1) name = name.strip() if not rest.endswith(")"): continue json_str = rest[:-1].strip() args = json.loads(json_str) invocations.append((name, args)) except Exception: continue return invocationsLLM 调用封装。这里就是走 TaoToken 的 OpenAI 兼容接口,把对话历史发过去,取回第一条回复的文本内容。
def execute_llm_call(conversation: List[Dict[str, str]]) -> str: system_content = "" messages = [] for msg in conversation: if msg["role"] == "system": system_content = msg["content"] else: messages.append(msg) payload = { "model": MODEL, "max_tokens": 2000, "messages": [{"role": "system", "content": system_content}] + messages, } headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", } resp = requests.post(CHAT_ENDPOINT, headers=headers, json=payload, timeout=120) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"]主循环。外层接收用户输入,内层反复调 LLM 和执行工具,直到没有工具调用为止。
def run_coding_agent_loop(): conversation = [{"role": "system", "content": get_full_system_prompt()}] print("Coding Agent 已启动,输入 exit 退出。\n") while True: try: user_input = input("You: ").strip() except (KeyboardInterrupt, EOFError): break if user_input.lower() in ("exit", "quit"): break conversation.append({"role": "user", "content": user_input}) while True: assistant_response = execute_llm_call(conversation) tool_invocations = extract_tool_invocations(assistant_response) if not tool_invocations: print(f"Assistant: {assistant_response}\n") conversation.append({"role": "assistant", "content": assistant_response}) break for name, args in tool_invocations: tool = TOOL_REGISTRY.get(name) if tool is None: result = {"error": f"unknown tool: {name}"} else: try: if name == "read_file": result = tool(args.get("filename", ".")) elif name == "list_files": result = tool(args.get("path", ".")) elif name == "edit_file": result = tool( args.get("path", "."), args.get("old_str", ""), args.get("new_str", ""), ) else: result = {"error": "unhandled tool"} except Exception as e: result = {"error": str(e)} print(f"[tool] {name} -> {json.dumps(result, ensure_ascii=False)[:200]}") conversation.append({ "role": "user", "content": f"tool_result({json.dumps(result, ensure_ascii=False)})", }) if __name__ == "__main__": run_coding_agent_loop()把上面所有代码块拼成一个agent.py,配上.env,就能直接python agent.py运行。整个文件大概 150 行左右,离 200 行还有余量,你可以自己加工具。
这里有个设计细节值得说:为什么工具执行结果要用role: user追加,而不是role: assistant?因为从模型视角看,工具结果是「外部世界给它的反馈」,属于输入信息,所以放在 user 角色里最自然。这也是 OpenAI 和 Anthropic 工具调用协议里的通用做法。理解这一点,你就能明白为什么多轮状态管理本质上就是「不断往 messages 数组里追加」这么简单。
4. 端到端验证:一次创建文件 + 修改文件的完整请求
代码写完了,得跑一次真实任务验证。我选了一个最经典的场景:先让 Agent 创建一个hello.py,再让它读取并追加一个乘法函数。这个任务会触发edit_file(创建)、read_file、edit_file(修改)三个动作,能完整覆盖工具调用循环。
启动脚本后,输入第一条指令:
You: 创建一个名为 hello.py 的新文件,内容是实现一个 hello 函数,打印 Hello World预期行为是:脚本把这条消息发给模型,模型返回类似tool: edit_file({"path": "hello.py", "old_str": "", "new_str": "def hello():\n print('Hello World')\n"})的一行。脚本解析后调用edit_file_tool,因为old_str为空,走创建分支,写入文件。然后脚本把tool_result({"path": "...", "action": "created_file"})追加到对话,再次调用模型。模型看到创建成功,返回一段普通文本,比如「已创建 hello.py,包含 hello 函数」。内层循环因为没有工具调用而退出,等待下一条用户输入。
终端输出大概长这样:
You: 创建一个名为 hello.py 的新文件,内容是实现一个 hello 函数,打印 Hello World [tool] edit_file -> {"path": "/your/dir/hello.py", "action": "created_file"} Assistant: 已完成,hello.py 已创建,包含 hello 函数。接着输入第二条指令:
You: 读取 hello.py,然后在文件末尾追加一个 multiply 函数,接收 a 和 b 两个参数并返回乘积这次模型会先调read_file拿到当前内容,再调edit_file把old_str设为文件末尾的某段内容、new_str设为原文加新函数。因为我们的edit_file是「替换第一次出现」,模型需要选一个唯一的锚点。实测下来,模型通常会选文件最后一行作为old_str,然后在后面拼接新函数。执行完成后,你可以打开hello.py确认内容:
def hello(): print('Hello World') def multiply(a, b): return a * b整个过程中,模型从来没有直接碰过文件系统,它只是输出了两行工具指令。真正读写文件的是我们那三个 Python 函数。这就是 Coding Agent 的全部秘密。
如果你想验证多轮状态管理是否正确,可以在主循环里加一行打印len(conversation),你会看到每轮对话后 messages 数组在增长。工具结果、助手回复、用户输入都按顺序追加,模型每次都能看到完整历史。这也是为什么长任务会消耗大量 Token——历史越长,每次请求携带的上下文越大。商用产品会做上下文压缩和摘要,我们的极简版先不做,理解原理优先。
再补一个验证点:故意让模型调用一个不存在的工具,比如在提示词里临时加一个delete_file但不在注册表里实现。你会看到脚本返回{"error": "unknown tool: delete_file"},模型收到这个结果后通常会道歉并改用现有工具。这说明工具注册表和错误反馈机制是有效的,模型能根据执行结果自我纠正。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth 逐条对照
跑这个 Agent 的过程中,最容易卡住的不是代码逻辑,而是环境配置。我把几个高频报错和对应排查思路列出来,你遇到时可以直接对照。
401 Unauthorized。这是最常见的一个,通常有三个原因:Key 没读到、Key 格式不对、请求头拼错。先确认.env文件在脚本同级目录,且load_dotenv()在读取环境变量之前调用。然后打印一下API_KEY[:8]看看是不是空字符串。如果 Key 是从控制台复制的,注意别把前后空格带进去。请求头必须是Authorization: Bearer sk-xxx,Bearer 和 Key 之间有一个空格,少这个空格也会 401。
local proxy failed / connection refused。这个报错说明请求根本没发出去,卡在网络层。检查BASE_URL是不是写成了https://taotoken.net/api,有没有多写斜杠或者少写协议头。如果你本地有设置HTTP_PROXY之类的环境变量,requests会自动读取,可能导致请求被转发到不可用的地址。可以在代码里显式传proxies={"http": None, "https": None}排除干扰。另外确认你的网络能正常访问外网 HTTPS 端口。
reading choices 报错 / KeyError: 'choices'。这个说明请求发出去了,也拿到了响应,但响应结构里没有choices字段。大概率是接口返回了一个错误 JSON,比如{"error": {"message": "model not found"}}。这时候别急着改解析代码,先把resp.text打印出来看原始响应。常见原因是 Model ID 拼错,或者你的账户没有该模型的权限。还有一种情况是max_tokens设得太大超过了模型上限,接口会直接报错。
OAuth / authentication 相关报错。如果你之前用过某些 CLI 工具做过 OAuth 登录,本地可能残留了凭证文件,某些 SDK 会优先读这些文件而不是你传的 Key。我们的代码是纯requests手写,不依赖任何 SDK,所以理论上不会触发。但如果你在同一个项目里混用了其他库,建议检查一下有没有全局的配置文件被读取。最稳妥的做法是新建一个干净的虚拟环境,只装requests和python-dotenv。
工具调用解析失败,模型回复被当成普通文本。这个不是报错,但表现是 Agent「不干活」。原因是模型输出的 JSON 格式不符合预期,比如用了单引号、多行、或者带了 markdown 代码块标记。排查方法是把assistant_response原样打印出来看。如果模型习惯性输出 ```json 包裹,你可以在解析前先做一次清洗,把代码块标记去掉。另一个办法是在系统提示词里加一句「不要使用 markdown 代码块,直接输出单行 JSON」,通常能显著改善。
edit_file 返回 old_str not found。这说明模型给的锚点文本和文件实际内容对不上,可能是空格、换行符差异。可以在工具函数里加一个容错:先尝试精确匹配,失败后尝试去掉首尾空白再匹配。更根本的解法是让模型先read_file再编辑,确保它看到的是最新内容。这也是为什么主循环里模型通常会先读后写。
把这几条对照着排查,基本能覆盖 95% 的启动问题。剩下的就是模型能力问题,换个指令遵循更强的模型通常能解决。
6. 从极简版到生产级:下一步该往哪里扩展
跑通这个 150 行的版本之后,你对 Coding Agent 的理解就已经超过大多数「只会用不会拆」的人了。接下来如果想继续深入,有几个方向可以扩展,每个方向都对应商用产品里的一个真实模块。
第一个方向是加工具。目前只有读、列、写三个,你可以照着同样的模式加run_shell(执行命令)、grep_search(代码检索)、web_fetch(抓网页)。加工具只需要三步:写函数、加 docstring、注册到TOOL_REGISTRY。系统提示词会自动更新,因为工具描述是动态生成的。这就是为什么我说这个骨架的扩展性很好。
第二个方向是流式输出。现在每次都要等模型完整回复才显示,体验上差一截。OpenAI 兼容接口支持stream: true,你可以把execute_llm_call改成逐块读取,边收边打印。注意流式模式下工具调用的解析要等完整响应拼完再做,不能边流边解析。
第三个方向是上下文管理。长任务跑久了 messages 会越来越长,Token 消耗和延迟都会上升。简单做法是保留最近 N 轮对话,更复杂的做法是让模型自己总结历史。商用产品通常两者结合。
第四个方向是权限确认。现在模型可以无限制写文件,生产环境肯定不行。可以在edit_file_tool执行前加一个input("确认修改?"),或者维护一个白名单目录。这是安全底线,别省。
如果你想把 Agent 接到更完整的编码工作流里,比如让它长期跑任务、管理多个会话,可以了解一下 Coding Plan 这类方案,它把模型调用、额度管理、多会话编排打包好了,省去自己维护通道的麻烦。而如果你只是想快速验证某个模型在工具调用上的表现,直接用模型对话页面测几条指令,比写代码快得多。
最后说个我自己的体会:这个循环最反直觉的地方在于,模型的能力上限和工具的设计质量是强耦合的。同样一个模型,工具描述写得清楚、参数命名直观、错误信息友好,它的表现就会好很多。所以别把精力全花在换模型上,先把工具 schema 和系统提示词打磨好,收益往往更大。