1. 从零拆解 Coding AgentLLM 工具调用循环到底在做什么很多人第一次用 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 类工具的底层原理还能得到一个可以自己扩展的本地脚本想加什么工具就加什么工具。在动手之前先把整体数据流画清楚。一次完整的工具调用会经历这些阶段用户输入进入对话历史脚本把历史发给 LLMLLM 返回文本其中可能包含tool: read_file({filename: app.py})这样的指令脚本用正则或字符串解析提取出工具名和 JSON 参数脚本在本地执行对应函数执行结果被包装成tool_result(...)追加到对话历史脚本再次调用 LLMLLM 看到结果后决定是继续调工具还是给出最终回答。这个内层循环会一直转直到某次 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_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELclaude-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, encodingutf-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, encodingutf-8) return {path: str(full_path), action: created_file} original full_path.read_text(encodingutf-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, encodingutf-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 ( fName: {tool_name}\n fDescription: {tool.__doc__}\n fSignature: {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: fBearer {API_KEY}, } resp requests.post(CHAT_ENDPOINT, headersheaders, jsonpayload, timeout120) 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(fAssistant: {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: funknown 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_asciiFalse)[:200]}) conversation.append({ role: user, content: ftool_result({json.dumps(result, ensure_asciiFalse)}), }) 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-xxxBearer 和 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 和系统提示词打磨好收益往往更大。