
1. 为什么我要自己搓一个 CLI Agent先说清楚这个东西是什么。它是一个跑在终端里的 AI 编程助手你输入一句需求它能自己决定读哪个文件、改哪一行、跑什么命令然后把结果反馈给模型继续下一轮直到任务完成。适合谁适合想搞明白 Agent 底层到底怎么跑的个人开发者尤其是预算有限、又不想被某个闭源工具锁死模型的那批人。商用 CLI Agent 的痛点其实很集中。闭源的那类模型锁死、按量收费、扩展性基本为零开源的那类功能散落在十几个仓库里配置成本高得离谱。我试过把某几个开源方案拼起来用光是把工具调用链路跑通就花了一整个周末。但真正让我下决心自己写的原因是我发现 Claude Code 这类工具的核心根本不是模型。模型只是大脑真正值钱的是外面那层运行时——Agent Loop 的调度逻辑、工具注册与执行、权限拦截、会话记忆。这套东西谁都能写只是大部分人没意识到它才是壁垒。所以这篇的目标很明确用最小的代码量复刻出一条能跑通的 Agent 链路。不追求企业级稳定性不搞多子代理并发就做个人够用的版本。核心是三件事——Agent Loop 调度、MCP 工具接入、统一 Key 管理。下面我会给出可复制的目录结构、Loop 伪代码、MCP 配置片段最后演示一次真实的本地工具调用和报错排查。你跟着做下来会得到一个能读文件、写文件、列目录、执行 shell 的终端 Agent并且能通过 MCP 挂载外部工具。代码量控制在几百行以内Python 3.10 就能跑。2. TaoToken 统一 Key 的前置准备在写 Loop 之前得先把模型调用这层解决掉。自己搓 Agent 最烦的一点是今天想用这个模型明天想换那个每换一次就要改 base_url、改 key、改 model id散落在代码各处。所以我用一个统一入口来管这些——TaoToken 提供 OpenAI 兼容的接口一个 Key 就能切换不同模型省掉了到处改配置的麻烦。它的定位是模型聚合网关对个人开发者来说最大的价值就是你不用为每个模型单独申请账号、单独记 Key。Agent 代码里只认一个 base_url 和一个 api_key换模型只改 model 字段。先拿 Key。打开 https://taotoken.net/api-keys 登录后创建一个 API Key复制出来。注意这个 Key 只在创建时完整显示一次丢了就得重建。拿到 Key 之后你需要确认两件事Base URL 是https://taotoken.net/api以及你要用的 Model ID。Model ID 可以在模型列表页查也可以直接在对话页试。如果你想先验证 Key 能不能用去 https://taotoken.net/model-chat 发一条消息最快不用写代码。这里有个坑要提前说Base URL 结尾不要自己加/v1。OpenAI SDK 会自动补路径你手动加了反而会变成/v1/v1/chat/completions直接 404。我踩过这个排查了半小时才发现是路径重复。对于长期要跑 Agent 的场景比如你打算让它常驻终端、频繁调用可以考虑 Coding Planhttps://taotoken.net/coding-plan 按套餐走比按量计费更可控。个人测试阶段用按量就够。Key 管理这块我强烈建议用.env文件不要硬编码进代码。原因很简单你迟早会把代码传到 GitHub硬编码的 Key 等于公开泄露。.env加.gitignore是最低成本的防护。# .env TAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELclaude-sonnet-4-5Model ID 具体填什么取决于你想用哪个模型。可以在 https://taotoken.net/doc 查最新的可用列表。填错 Model ID 的报错通常是model not found这个后面排障章节会细讲。3. 可复制的 CLI 目录结构与配置现在开始搭骨架。目录结构我尽量扁平个人项目不需要过度分层层级太深反而找文件费劲。mini-claude/ ├── main.py # 入口TUI 启动 ├── agent/ │ ├── loop.py # Agent Loop 核心 │ ├── llm.py # 模型调用封装 │ ├── tools.py # 本地工具集 │ ├── sandbox.py # 权限拦截 │ └── session.py # 会话持久化 ├── mcp_client/ │ └── client.py # MCP 客户端 ├── config/ │ ├── mcp.json # MCP 服务配置 │ └── settings.toml # 全局设置 ├── .env └── requirements.txt依赖装这些pip install openai python-dotenv pyyaml mcp textualtextual是可选的如果你只想先跑通逻辑用普通input()循环也行TUI 后面再加。接下来是配置文件。config/settings.toml放全局参数[model] base_url https://taotoken.net/api model_id claude-sonnet-4-5 max_tokens 4096 temperature 0.2 [agent] max_loop 20 auto_confirm false [session] save_dir ./.sessionsconfig/mcp.json放 MCP 服务定义。这里我用一个本地 stdio 服务做示例挂一个文件系统工具{ mcpServers: { filesystem: { command: npx, args: [-y, modelcontextprotocol/server-filesystem, ./workspace], env: {} } } }注意command和args的写法这是 MCP 官方约定的 stdio 传输格式。env里可以塞环境变量比如某些 MCP 服务需要单独的 token。然后是模型调用封装agent/llm.py这是统一 Key 落地的地方import os from openai import OpenAI from dotenv import load_dotenv load_dotenv() client OpenAI( api_keyos.getenv(TAOTOKEN_API_KEY), base_urlos.getenv(TAOTOKEN_BASE_URL, https://taotoken.net/api), ) def chat(messages, toolsNone, modelNone): resp client.chat.completions.create( modelmodel or os.getenv(TAOTOKEN_MODEL), messagesmessages, toolstools, temperature0.2, ) return resp.choices[0].message这段代码的关键点base_url只写一次所有模型调用都走这里。换模型只改model参数不动其他任何东西。这就是统一 Key 管理的实际收益。工具定义agent/tools.py先实现四个核心工具import os, subprocess, json TOOLS_SCHEMA [ { type: function, function: { name: read_file, description: 读取指定路径的文件内容, parameters: { type: object, properties: {path: {type: string}}, required: [path], }, }, }, { type: function, function: { name: write_file, description: 写入或覆盖文件, parameters: { type: object, properties: { path: {type: string}, content: {type: string}, }, required: [path, content], }, }, }, { type: function, function: { name: list_dir, description: 列出目录内容, parameters: { type: object, properties: {path: {type: string}}, required: [path], }, }, }, { type: function, function: { name: shell, description: 执行 shell 命令, parameters: { type: object, properties: {cmd: {type: string}}, required: [cmd], }, }, }, ] def run_tool(name, args): try: if name read_file: with open(args[path], r, encodingutf-8) as f: return {content: f.read()} if name write_file: os.makedirs(os.path.dirname(args[path]) or ., exist_okTrue) with open(args[path], w, encodingutf-8) as f: f.write(args[content]) return {ok: True} if name list_dir: return {items: os.listdir(args[path])} if name shell: r subprocess.run( args[cmd], shellTrue, capture_outputTrue, textTrue, timeout30, ) return {stdout: r.stdout, stderr: r.stderr, code: r.returncode} except Exception as e: return {error: str(e)} return {error: funknown tool: {name}}到这里配置和工具层就齐了。下一节写 Loop 本体。4. Agent Loop 与 MCP 接入的完整实现Agent Loop 是整个项目的心脏。它的逻辑其实不复杂把消息发给模型模型返回要么是纯文本任务结束要么是工具调用继续循环。循环的终止条件是模型不再请求工具。agent/loop.pyimport json from agent.llm import chat from agent.tools import TOOLS_SCHEMA, run_tool from agent.sandbox import check_danger from agent.session import save_session, load_session def agent_loop(user_input, session_iddefault): messages load_session(session_id) messages.append({role: user, content: user_input}) for step in range(20): msg chat(messages, toolsTOOLS_SCHEMA) messages.append(msg.model_dump()) if not msg.tool_calls: save_session(session_id, messages) return msg.content for tc in msg.tool_calls: name tc.function.name args json.loads(tc.function.arguments) if check_danger(name, args): confirm input(f高危操作 {name} {args}执行? y/n: ) if confirm ! y: result {error: 用户拒绝执行} else: result run_tool(name, args) else: result run_tool(name, args) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse), }) save_session(session_id, messages) return 达到最大循环次数任务中止几个关键细节。第一msg.model_dump()把模型返回的对象转成 dict 再塞回 messages这是 OpenAI SDK 的标准做法直接 append 对象在某些版本会报序列化错误。第二工具结果必须带tool_call_id否则模型无法把结果和请求对应起来会报missing tool_call_id。第三max_loop设 20 是防止模型陷入死循环个人用够了。权限拦截agent/sandbox.pyDANGER_PATTERNS [rm -rf, sudo, chmod 777, /etc, mkfs] def check_danger(name, args): if name shell: cmd args.get(cmd, ) return any(p in cmd for p in DANGER_PATTERNS) if name write_file: return args.get(path, ).startswith(/etc) return False这个拦截很粗糙但个人用足够。它的意义不是防黑客是防模型手滑删你项目。会话持久化agent/session.pyimport json, os SAVE_DIR ./.sessions def save_session(sid, messages): os.makedirs(SAVE_DIR, exist_okTrue) with open(f{SAVE_DIR}/{sid}.json, w, encodingutf-8) as f: json.dump(messages, f, ensure_asciiFalse, indent2) def load_session(sid): path f{SAVE_DIR}/{sid}.json if os.path.exists(path): with open(path, r, encodingutf-8) as f: return json.load(f) return []MCP 接入mcp_client/client.py用官方 SDK 连 stdio 服务import json from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def load_mcp_tools(config_pathconfig/mcp.json): with open(config_path) as f: cfg json.load(f) tools [] for name, spec in cfg[mcpServers].items(): params StdioServerParameters( commandspec[command], argsspec[args], envspec.get(env), ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() resp await session.list_tools() for t in resp.tools: tools.append({ type: function, function: { name: fmcp_{name}_{t.name}, description: t.description, parameters: t.inputSchema, }, }) return toolsMCP 工具加载是异步的实际集成时你需要在启动阶段跑一次asyncio.run(load_mcp_tools())把返回的 schema 合并进TOOLS_SCHEMA。调用 MCP 工具时走session.call_tool()而不是本地run_tool路由逻辑按工具名前缀mcp_判断。入口main.pyfrom agent.loop import agent_loop if __name__ __main__: print(mini-claude ready. CtrlC to exit.) while True: try: q input(\n ) if not q.strip(): continue print(agent_loop(q)) except KeyboardInterrupt: break跑起来python main.py到这里一条完整的 Agent 链路就通了。模型负责决策Loop 负责调度工具负责执行MCP 负责扩展。5. 验证请求与常见报错排查先做一次最小验证。启动后输入 列出当前目录的文件预期行为模型返回一个list_dir工具调用Loop 执行后把结果回传模型再返回一段自然语言总结。如果你看到类似「当前目录下有 main.py、agent、config...」的输出说明链路通了。再验证写文件 创建一个 hello.txt内容写 agent loop works这次会触发write_file。执行完检查一下文件是否真的生成了。下面是我实际踩过的几个报错按出现频率排。401 Unauthorized。最常见。原因通常是 Key 没读到或者.env没被加载。检查两点load_dotenv()是否在OpenAI()初始化之前调用环境变量名是否和代码里一致。如果 Key 是从 https://taotoken.net/api-keys 复制的注意别把前后空格带进去。local proxy failed / connection error。这个报错一般是 base_url 写错了。确认是https://taotoken.net/api不要加/v1不要加结尾斜杠。如果你本地有系统级代理设置也可能干扰检查一下环境变量里的HTTP_PROXY。reading choices of undefined。这个报错说明返回体结构不对通常是 base_url 指向了一个不兼容 OpenAI 格式的端点。TaoToken 是 OpenAI 兼容的正常不会出现。如果出现先确认你请求的路径是/chat/completions。model not found。Model ID 填错了。去 https://taotoken.net/doc 核对准确的 ID 字符串大小写敏感。missing tool_call_id。工具结果回传时漏了tool_call_id字段。检查messages.append那段role: tool的消息必须带这个字段。OAuth / 认证失败。如果你用的是某些需要 OAuth 的 MCP 服务stdio 模式下通常不需要 OAuth但如果是远程 MCP需要单独配置 token。本地 stdio 服务一般不会遇到。MCP 服务启动失败。检查mcp.json里的command是否在 PATH 里。npx需要 Node 环境没装 Node 会直接报 command not found。可以先在终端手动跑一遍npx -y modelcontextprotocol/server-filesystem ./workspace确认能启动。排障的通用思路先在 https://taotoken.net/model-chat 用同样的 Key 发一条消息确认 Key 本身没问题再回到代码里加日志把每次请求的messages和返回打出来。大部分问题都能靠这两步定位。6. 继续迭代的方向跑通最小版本之后能加的东西很多但别一次全上。我的建议是按需迭代。第一优先是上下文压缩。会话跑长了messages 会越来越长token 消耗飙升。简单做法是保留最近 N 轮把更早的对话用模型总结成一段摘要塞回去。这个逻辑不复杂但能显著降低长期使用的成本。第二是本地记忆。把每次会话的关键结论存进一个向量库下次启动时检索相关片段注入 system prompt。这样 Agent 能记住你项目的架构约定不用每次重复交代。第三是子代理。复杂任务拆成多个子任务每个子任务起一个独立的 Loop最后汇总。这个属于进阶个人项目不一定需要。第四是模型切换。因为用了统一 Key切换模型只是改一个字段。你可以在 settings.toml 里配多个模型按任务类型路由——简单任务用便宜的复杂推理用强的。最后说一句实在的这套东西的价值不在于它能替代商用工具而在于你彻底搞懂了 Agent 运行时是怎么运转的。模型谁都能调但能自己搭一套调度系统这个能力是分水岭。代码我尽量给到能直接跑的程度剩下的就看你往里加什么了。