1. 从 OpenClaw 源码看 Agent 到底由什么组成
OpenClaw 是一个跑在生产环境里的 AI Agent 框架,代码量不小,但核心就四个模块——执行循环、工具系统、记忆系统、插件系统。这篇文章把每个模块拆开看看里面怎么写的,最后整理一份自己做 Agent 时可以参考的清单,并且给出一份可以直接复制的 TaoToken 配置骨架,让你在本地把 Agent 跑起来。
如果你熟悉 TypeScript 和 LLM API 的基本概念,读起来会很顺。如果你还不了解 Function Calling,建议先补一下工具调用(Tool Use)的基础概念,再回来看源码结构会清晰很多。
一句话概括 OpenClaw 的架构:
Gateway 接收消息 → Agent 循环调用 LLM + 工具 → 记忆系统提供上下文 → 插件扩展一切。
四个模块各管各的,耦合度不高。先扫一眼目录结构:
| 目录 | 一句话说明 |
|---|---|
| src/agents/ | Agent 执行循环、工具注册、模型管理 |
| src/memory/ | 记忆索引、嵌入向量、混合检索 |
| src/gateway/ | WebSocket 网关、认证、RPC |
| src/plugin-sdk/ | 插件 SDK、Hook 系统 |
| src/channels/ | 通道抽象层(状态机、路由、线程绑定) |
| extensions/ | 73 个插件(通道 / LLM Provider / 工具扩展) |
这篇的重点不是把每个文件都念一遍,而是把「构建一个 Agent 需要哪些零件」讲清楚,然后给你一份能直接跑的配置骨架。LLM 调用通道这块,我用 TaoToken 做统一入口,一个 Key 就能覆盖多种模型,省得在多个 Provider 之间来回切。
2. TaoToken 前置:统一 Key 与 API 通道
在动手写 Agent 之前,先把 LLM 调用通道准备好。OpenClaw 的模型管理模块(src/agents/ 下的 Provider 相关代码)本质上就是维护一组「Provider + Auth Profile + 模型名」的映射,然后按优先级做 Failover。你自己做 Agent 时,如果每个 Provider 都单独配 Key、单独处理限流和重试,代码会迅速膨胀。
TaoToken 在这里扮演的角色是统一入口:一个 API Key,一个 Base URL,就能调用多种模型。对 Agent 来说,这意味着你的 LLM 调用层只需要维护一套认证逻辑,模型切换只是改一个字符串。
你需要准备的东西:
- 一个 TaoToken 账号,登录后在控制台创建 API Key
- 记下 API Base URL:
https://taotoken.net/api - 选一个默认模型名(比如
claude-sonnet-4-20250514或gpt-4o,具体以控制台模型列表为准)
控制台入口在这里:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
API Key 管理页面:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
拿到 Key 之后,先别急着写 Agent,用一条 curl 验证通道是否通:
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": 32 }'如果返回里能看到choices[0].message.content,说明通道没问题。这一步很重要,因为后面 Agent 报错时,你需要快速判断是「通道问题」还是「Agent 逻辑问题」。把通道单独验证过,排障范围就缩小了一半。
注意:API Key 不要硬编码进源码。用环境变量
TAOTOKEN_API_KEY,或者放进.env文件并加进.gitignore。
3. 可复制配置:settings.json 与 config.toml 骨架
OpenClaw 的配置入口分散在几个地方,但核心就两类:一类是「运行时配置」(模型、通道、记忆),一类是「Agent 行为配置」(System Prompt、工具开关、循环上限)。下面给两份骨架,你可以直接复制到自己的项目里改。
3.1 settings.json:Agent 运行时配置
这份配置对应 OpenClaw 里src/agents/和src/memory/的初始化参数。我把它整理成一份扁平结构,方便你对照源码理解每个字段的作用:
{ "agent": { "id": "my-first-agent", "name": "本地 Agent 雏形", "maxToolRounds": 12, "stream": true, "systemPromptFile": "./prompts/system.md" }, "llm": { "provider": "taotoken", "baseUrl": "https://taotoken.net/api", "apiKeyEnv": "TAOTOKEN_API_KEY", "model": "claude-sonnet-4-20250514", "fallbackModels": ["gpt-4o", "claude-haiku-4-20250514"], "timeoutMs": 60000, "maxRetries": 3 }, "tools": { "enabled": ["read_file", "write_file", "exec", "web_search"], "requireConfirm": ["write_file", "exec"], "loopDetection": { "windowSize": 30, "warnAt": 10, "stopAt": 20, "abortAt": 30 } }, "memory": { "enabled": true, "dbPath": "./data/memory.sqlite", "embeddingProvider": "taotoken", "embeddingModel": "text-embedding-3-small", "hybridWeights": { "vector": 0.7, "bm25": 0.3 }, "temporalDecayHalfLifeDays": 30 } }几个字段值得单独说:
maxToolRounds对应 OpenClaw 里工具循环的上限。设太小,复杂任务跑不完;设太大,一旦逻辑出错会烧很多 token。12 是个比较稳的起步值。
fallbackModels就是 Failover 的简化版。主模型限流或报错时,按顺序切下一个。OpenClaw 的run.ts里做得更细,会冷却出问题的 Auth Profile,但核心思路一致。
loopDetection直接抄了 OpenClaw 的三级熔断:10 次警告、20 次强制提示、30 次终止。滑动窗口大小 30 是它的默认值。
3.2 config.toml:通道与网关配置
如果你要接消息通道(Discord、Slack、Web UI),这部分对应src/gateway/和src/channels/:
[gateway] host = "127.0.0.1" port = 8787 auth_token_env = "GATEWAY_TOKEN" [channels.web] enabled = true path = "/chat" [channels.discord] enabled = false bot_token_env = "DISCORD_BOT_TOKEN" [state_machine] idle_timeout_sec = 300 max_concurrent_sessions = 4 [logging] level = "info" file = "./logs/agent.log"state_machine这段对应 OpenClaw 的src/channels/run-state-machine.ts。每个会话有独立状态(idle → running → drafting → completed),保证同一会话不会被并发请求搞乱。你自己做的时候,哪怕先不做完整状态机,至少也要给每个会话加一把锁。
3.3 System Prompt 骨架
OpenClaw 的system-prompt.ts是动态拼装的——运行时信息、工具列表、通道能力、用户指令按需注入。你可以先从一个静态文件开始:
你是运行在本地环境中的 AI Agent。 ## 运行环境 - 操作系统:{{os}} - 当前时间:{{now}} - 工作目录:{{cwd}} ## 可用工具 {{tool_list}} ## 行为准则 1. 需要读取文件时,先调用 read_file,不要凭记忆猜测内容。 2. 执行有副作用的操作(写文件、跑命令)前,先说明你要做什么。 3. 如果连续两次工具调用没有进展,停下来向用户确认。{{tool_list}}由代码在启动时注入,格式就是工具名 + 描述。LLM 靠这段描述判断什么时候该调哪个工具,所以描述要写清楚「做什么」和「什么时候用」。
4. 验证请求:本地启动并跑通一次 Agent 响应
配置写好了,接下来把它跑起来。下面给一个最小可运行的 TypeScript 入口,对应 OpenClaw 的src/agents/pi-embedded-runner/run/attempt.ts——单次 LLM 调用加工具循环。
4.1 安装依赖
npm init -y npm install openai dotenv npm install -D typescript tsx @types/node这里用openai这个 SDK 就行,因为 TaoToken 的 API 兼容 OpenAI 的请求格式,改一下baseURL就能用。
4.2 核心循环代码
// src/agent.ts import OpenAI from "openai"; import "dotenv/config"; const client = new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY!, baseURL: "https://taotoken.net/api/v1", }); const tools = [ { type: "function" as const, function: { name: "read_file", description: "读取指定路径的文件内容", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"], }, }, }, ]; async function runAgent(userInput: string) { const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: "你是一个本地 Agent,需要读文件时调用 read_file。" }, { role: "user", content: userInput }, ]; for (let round = 0; round < 12; round++) { const res = await client.chat.completions.create({ model: "claude-sonnet-4-20250514", messages, tools, stream: false, }); const msg = res.choices[0].message; messages.push(msg); if (!msg.tool_calls || msg.tool_calls.length === 0) { console.log("最终回复:", msg.content); return msg.content; } for (const call of msg.tool_calls) { const args = JSON.parse(call.function.arguments); let result = ""; if (call.function.name === "read_file") { try { result = await import("fs/promises").then((fs) => fs.readFile(args.path, "utf-8")); } catch (e) { result = `读取失败:${(e as Error).message}`; } } messages.push({ role: "tool", tool_call_id: call.id, content: result.slice(0, 4000), }); } } throw new Error("工具循环超过上限,已终止"); } runAgent("读一下 package.json,告诉我项目名和依赖数量");4.3 运行与预期结果
npx tsx src/agent.ts正常的话,你会看到 Agent 先发起一次read_file工具调用,拿到文件内容后,再生成一段自然语言回复,类似:
最终回复:项目名是 my-agent-demo,dependencies 里有 2 个依赖:openai 和 dotenv。这个过程就是 Agent 的最小骨架:LLM 决定调工具 → 代码执行工具 → 结果喂回 LLM → LLM 生成最终回复。OpenClaw 的attempt.ts做的也是这件事,只是外面包了流式处理、容错、上下文压缩。
4.4 加上流式输出
把stream: false改成true,然后处理text_delta事件,用户就能边生成边看到字。OpenClaw 的pi-embedded-subscribe.ts就是干这个的,它还会在语义边界处切分文本块,避免把半个句子推给用户。
const stream = await client.chat.completions.create({ model: "claude-sonnet-4-20250514", messages, tools, stream: true, }); for await (const chunk of stream) { const delta = chunk.choices[0]?.delta; if (delta?.content) process.stdout.write(delta.content); }流式模式下,工具调用的参数是分片到达的,需要自己拼接tool_calls的arguments字符串,等finish_reason变成tool_calls再解析。这是新手最容易踩的坑之一。
5. 本篇常见错排查
5.1 401 / 403:Key 没读到或格式不对
最常见的原因是环境变量没加载。dotenv/config要在最顶部导入,且.env文件里写的是TAOTOKEN_API_KEY=sk-xxx,不要加引号。如果用的是 shell 导出,确认echo $TAOTOKEN_API_KEY有输出。
另一个原因是 Base URL 写错。注意区分:https://taotoken.net/api是根路径,SDK 里通常要写https://taotoken.net/api/v1。如果你用的是原生 fetch 拼/v1/chat/completions,那就用根路径。
5.2 模型名报错 model_not_found
模型名要以控制台模型列表为准,不要凭记忆写。不同 Provider 的命名风格不一样,有的带日期后缀,有的不带。建议把模型名放进配置文件,别散落在代码里。
5.3 工具调用死循环
表现是 Agent 反复调同一个工具,token 哗哗烧。原因通常是工具返回的结果 LLM 看不懂,或者 System Prompt 没告诉它「拿到结果后该干什么」。
排查方法:把每轮的工具调用名和参数打出来,看是不是同一个调用重复出现。修复方法有两个,一是把工具返回内容结构化(别返回一坨乱码),二是在循环里加计数,超过阈值就强制让 LLM 生成最终回复。
const callCount = new Map<string, number>(); // 在每次工具调用前 const key = `${call.function.name}:${call.function.arguments}`; const n = (callCount.get(key) ?? 0) + 1; callCount.set(key, n); if (n >= 3) { messages.push({ role: "user", content: "同一工具已重复调用多次,请基于现有信息直接回答。" }); }5.4 上下文超长导致请求失败
长对话跑到后面,messages 数组会超过模型窗口。OpenClaw 的做法是用 Context Engine 对历史做摘要压缩(compact.ts),而不是简单砍掉前面的消息。你自己实现时,可以先做一个简化版:保留 System Prompt 和最近 N 轮对话,把更早的内容用一次 LLM 调用总结成一段话。
5.5 流式模式下工具调用解析失败
前面提过,流式返回的tool_calls是分片的。delta.tool_calls[0].function.arguments每次只给你一小段 JSON 字符串,需要按index累积拼接,等流结束后再JSON.parse。直接对每个 chunk 解析会报Unexpected end of JSON input。
5.6 记忆检索返回空结果
如果你接了记忆系统,搜索时返回空,先检查三件事:嵌入模型是否配置正确、SQLite 里chunks表是否有数据、查询向量维度是否和存储时一致。维度不一致是最隐蔽的坑,比如存储用 1536 维,查询用了 1024 维的模型,相似度计算会直接失效。
6. 把 Agent 跑起来之后,下一步做什么
到这里,你已经有了一个能跑的最小 Agent:统一 Key 通道、可复制配置、工具循环、流式输出、基础排障。接下来按优先级补三件事。
第一是容错。裸循环跑 demo 没问题,上线必须加 Auth Failover 和上下文压缩。前者解决限流和 Key 过期,后者解决长对话崩溃。这两块 OpenClaw 的run.ts和compact.ts都有现成思路可以抄。
第二是记忆。让 Agent 从「一次性对话」变成「持续助手」,最小实现就是 SQLite 加向量检索加 FTS5 全文兜底。嵌入模型选text-embedding-3-small就够用,便宜且稳定。混合检索权重先用 0.7 向量加 0.3 BM25,跑一段时间再调。
第三是扩展。等你要接第二个通道、加第三个工具的时候,再考虑插件化和 Hook 系统。不用一开始就做 25 个 Hook,先从before_prompt_build和before_tool_call这两个高频的做起。
如果你在接入过程中遇到通道或 Key 的问题,可以直接去 API Keys 页面重新生成一个验证:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
想先验证模型响应是否符合预期,可以用模型对话页面快速试一条: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
我自己的习惯是,每加一个新工具,先用模型对话页面手动构造一次工具调用请求,确认返回格式对了,再写进 Agent 代码。这样能把「模型问题」和「代码问题」分开,排障快很多。