其实你自己就能做出 OpenClaw(教程在这)
说实话,我第一次听说 OpenClaw 的时候,以为这是什么只有博士级工程师才能搞懂的黑科技。结果呢?一旦拆开来看,其实挺简单的。
整个东西本质上就是一个网关——把 AI 连接到你的消息应用,给它一些操作电脑的工具,然后让它记住你是谁。就这样。难的地方在于,当你同时跑多个渠道时,怎么让它稳定运行。
我的做法是:从零开始,自己重新实现了核心组件。没有花哨的框架,就是一个消息 API、一个大模型,加上一些耐心。说真的,你也完全可以做到。
没人说的那个问题
浏览器里的 ChatGPT 和 Claude,说白了不太适合做真正的工作。别误会,它们确实很聪明。但是:
每次对话都从头开始。它不知道你叫什么、昨天做了什么,什么都不知道。你永远在重新介绍自己。
你得主动去找它。它不会主动来找你。想让它早上七点帮你检查日历?不行,它只在你坐在那儿打字的时候才工作。
它被困在一个文本框里。不能运行命令,不能帮你浏览网页,除了聊天什么实际的事都做不了。
它只活在一个标签页里。你的生活分散在 WhatsApp、Telegram、Discord、Slack 各处,但你的 AI?被困在自己的小窗口里。
如果有一个 AI 住在你真正在用的消息应用里,记得所有事情,能控制你的电脑,在你自己的硬件上 7×24 小时运行,那会怎样?
那就是 OpenClaw。我们来做一个。
从最简单的开始
首先,做一个能响应 Telegram 消息的 AI:
# bot-v0.py - 最简单的 AI 机器人import osimport anthropicfrom telegram import Updatefrom telegram.ext import Application, MessageHandler, filtersclient = anthropic.Anthropic()async def handle_message(update: Update, context):user_message = update.message.textresponse = client.messages.create(model="claude-sonnet-4-5-20250929",max_tokens=1024,messages=[{"role": "user", "content": user_message}])await update.message.reply_text(response.content[0].text)app = Application.builder().token(os.getenv("TELEGRAM_BOT_TOKEN")).build()app.add_handler(MessageHandler(filters.TEXT, handle_message))app.run_polling()
运行它,发一条消息,得到回复。挺酷的。但也完全没用,因为它没有任何记忆。问它”我刚才说了什么?”它完全不知道。
让它记住你
解决方法很简单——保存对话历史。我用的是 JSONL 文件,因为它崩溃安全。每行是一条消息,程序中途挂掉最多丢一行。
# bot-v1.py - 带持久会话的机器人import jsonimport osimport anthropicfrom telegram import Updatefrom telegram.ext import Application, MessageHandler, filtersclient = anthropic.Anthropic()SESSIONS_DIR = "./sessions"os.makedirs(SESSIONS_DIR, exist_ok=True)def get_session_path(user_id):return os.path.join(SESSIONS_DIR, f"{user_id}.jsonl")def load_session(user_id):"""从磁盘加载对话历史"""path = get_session_path(user_id)messages = []if os.path.exists(path):with open(path, "r") as f:for line in f:if line.strip():messages.append(json.loads(line))return messagesdef append_to_session(user_id, message):"""向会话文件追加一条消息"""path = get_session_path(user_id)with open(path, "a") as f:f.write(json.dumps(message) + "\n")def save_session(user_id, messages):"""用完整消息列表覆写会话文件"""path = get_session_path(user_id)with open(path, "w") as f:for message in messages:f.write(json.dumps(message) + "\n")async def handle_message(update: Update, context):user_id = str(update.effective_user.id)user_message = update.message.textmessages = load_session(user_id)user_msg = {"role": "user", "content": user_message}messages.append(user_msg)append_to_session(user_id, user_msg)response = client.messages.create(model="claude-sonnet-4-5-20250929",max_tokens=4096,messages=messages)assistant_msg = {"role": "assistant", "content": response.content[0].text}append_to_session(user_id, assistant_msg)await update.message.reply_text(response.content[0].text)app = Application.builder().token(os.getenv("TELEGRAM_BOT_TOKEN")).build()app.add_handler(MessageHandler(filters.TEXT, handle_message))app.run_polling()
现在你可以真正进行对话了:
你: 我叫 Rolly 机器人: 很高兴认识你,Rolly!
(几小时后……)
你: 我叫什么名字? 机器人: 你叫 Rolly!
这正是 OpenClaw 存储对话的方式:~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl。一个会话,一个文件。重启所有东西,数据还在。
赋予它个性
现在我们的机器人太泛泛了,无聊。用一个 SOUL 文件给它一些性格(对,OpenClaw 真的就叫这个名字):
# bot-v2.py - 带个性的机器人SOUL = """# 你是谁**名字:** Jarvis**角色:** 个人 AI 助手## 个性- 真正有帮助,而不是表演式的帮助- 省掉"好问题!"——直接帮忙就好- 有自己的观点,你可以不同意- 需要简洁时简洁,需要详细时详细## 边界- 私密的事情保持私密- 有疑问时,在对外行动前先询问- 你不是用户的代言人——代替他们发送消息要谨慎## 记忆记住对话中的重要细节。如果某件事值得记,就写下来。"""async def handle_message(update: Update, context):user_id = str(update.effective_user.id)messages = load_session(user_id)user_msg = {"role": "user", "content": update.message.text}messages.append(user_msg)append_to_session(user_id, user_msg)response = client.messages.create(model="claude-sonnet-4-5-20250929",max_tokens=4096,system=SOUL, # <-- 个性在这里注入messages=messages)assistant_msg = {"role": "assistant", "content": response.content[0].text}append_to_session(user_id, assistant_msg)await update.message.reply_text(response.content[0].text)
SOUL 在每次调用时作为系统提示注入。现在你不是在和”通用 AI 助手 #47”对话,而是在和 Jarvis 对话。
在 OpenClaw 中,这个文件放在 ~/.openclaw/workspace/SOUL.md。想写什么都行,可以幽默,可以严肃,可以给它一个背景故事。越具体,它的行为就越一致。
真正做点事(工具)
一个只会聊天的机器人相当有限。如果它能运行命令、读取文件、搜索网页呢?
import subprocessTOOLS = [{"name": "run_command","description": "在用户的电脑上运行一个 shell 命令","input_schema": {"type": "object","properties": {"command": {"type": "string", "description": "要运行的命令"}},"required": ["command"]}},{"name": "read_file","description": "从文件系统读取一个文件","input_schema": {"type": "object","properties": {"path": {"type": "string", "description": "文件路径"}},"required": ["path"]}},{"name": "write_file","description": "向文件写入内容","input_schema": {"type": "object","properties": {"path": {"type": "string", "description": "文件路径"},"content": {"type": "string", "description": "要写入的内容"}},"required": ["path", "content"]}},{"name": "web_search","description": "在网络上搜索信息","input_schema": {"type": "object","properties": {"query": {"type": "string", "description": "搜索关键词"}},"required": ["query"]}}]def execute_tool(name, input):if name == "run_command":result = subprocess.run(input["command"], shell=True,capture_output=True, text=True, timeout=30)return result.stdout + result.stderrelif name == "read_file":with open(input["path"], "r") as f:return f.read()elif name == "write_file":with open(input["path"], "w") as f:f.write(input["content"])return f"已写入 {input['path']}"elif name == "web_search":# 简化版——实际使用时请接入真正的搜索 APIreturn f"搜索结果:{input['query']}"return f"未知工具:{name}"
现在我们需要 Agent 循环。当 AI 想使用工具时,我们运行它并把结果返回:
def serialize_content(content):"""将 API 响应内容块转换为可 JSON 序列化的字典"""serialized = []for block in content:if hasattr(block, "text"):serialized.append({"type": "text", "text": block.text})elif block.type == "tool_use":serialized.append({"type": "tool_use","id": block.id,"name": block.name,"input": block.input})return serializeddef run_agent_turn(messages, system_prompt):"""运行一个完整的 Agent 轮次(可能涉及多次工具调用)"""while True:response = client.messages.create(model="claude-sonnet-4-5-20250929",max_tokens=4096,system=system_prompt,tools=TOOLS,messages=messages)content = serialize_content(response.content)# 如果 AI 完成了(没有工具调用),返回文本if response.stop_reason == "end_turn":text = ""for block in response.content:if hasattr(block, "text"):text += block.textmessages.append({"role": "assistant", "content": content})return text, messages# 处理工具调用if response.stop_reason == "tool_use":messages.append({"role": "assistant", "content": content})tool_results = []for block in response.content:if block.type == "tool_use":print(f" 工具: {block.name}({json.dumps(block.input)})")result = execute_tool(block.name, block.input)tool_results.append({"type": "tool_result","tool_use_id": block.id,"content": str(result)})messages.append({"role": "user", "content": tool_results})
更新消息处理器:
async def handle_message(update: Update, context):user_id = str(update.effective_user.id)messages = load_session(user_id)messages.append({"role": "user", "content": update.message.text})response_text, messages = run_agent_turn(messages, SOUL)save_session(user_id, messages)await update.message.reply_text(response_text)
现在你可以发消息:
你: 创建一个叫 hello.py 的文件,打印 hello world,然后运行它
机器人: (使用 write_file 创建 hello.py) (使用 run_command 执行它) 完成!我创建了 hello.py 并运行了它。输出:”hello world”
AI 自己判断了用哪些工具、按什么顺序用。全程只靠一条文字消息。
别让它把你的电脑搞崩
我们在通过 Telegram 运行 shell 命令,这很吓人。如果有人黑了你的账号,让它执行 rm -rf / 怎么办?
我们需要权限控制:
import reSAFE_COMMANDS = {"ls", "cat", "head", "tail", "wc", "date", "whoami", "echo"}DANGEROUS_PATTERNS = [r"\brm\b", r"\bsudo\b", r"\bchmod\b", r"\bcurl.*\|.*sh"]# 持久化白名单APPROVALS_FILE = "./exec-approvals.json"def load_approvals():if os.path.exists(APPROVALS_FILE):with open(APPROVALS_FILE) as f:return json.load(f)return {"allowed": [], "denied": []}def save_approval(command, approved):approvals = load_approvals()key = "allowed" if approved else "denied"if command not in approvals[key]:approvals[key].append(command)with open(APPROVALS_FILE, "w") as f:json.dump(approvals, f, indent=2)def check_command_safety(command):"""返回 'safe'、'approved' 或 'needs_approval'"""base_cmd = command.strip().split()[0] if command.strip() else ""if base_cmd in SAFE_COMMANDS:return "safe"approvals = load_approvals()if command in approvals["allowed"]:return "approved"for pattern in DANGEROUS_PATTERNS:if re.search(pattern, command):return "needs_approval"return "needs_approval"
更新 run_command 的处理逻辑:
if name == "run_command":cmd = input["command"]safety = check_command_safety(cmd)if safety == "needs_approval":# 实际的机器人中,你会通过 Telegram 提示用户并等待回复# 为简化起见,这里直接记录日志并拒绝print(f" ⚠ 已拦截: {cmd}(需要授权)")return "权限被拒绝。该命令需要授权。"result = subprocess.run(cmd, shell=True, capture_output=True,text=True, timeout=30)return result.stdout + result.stderr
安全命令立即执行。可疑命令被拦截。授权记录保存到 exec-approvals.json,同一条命令不会被问两次。
网关模式
这里开始变得有意思了。现在我们有了一个 Telegram 机器人,但 Discord 呢?WhatsApp 呢?Slack 呢?
你可以写多个独立的机器人。但那样的话对话和记忆就是分开的——Telegram 上的 AI 不知道你在 Discord 上聊了什么。
解决方法:一个统一的中央网关,处理所有渠道。注意,我们的 run_agent_turn 函数对 Telegram 一无所知,它只接收消息、返回文本。所以我们可以随意添加任何界面。
来证明一下,在 Telegram 旁边加一个 HTTP API:
from flask import Flask, request, jsonifyimport threadingflask_app = Flask(__name__)@flask_app.route("/chat", methods=["POST"])def chat():data = request.jsonuser_id = data["user_id"]messages = load_session(user_id)messages.append({"role": "user", "content": data["message"]})response_text, messages = run_agent_turn(messages, SOUL)save_session(user_id, messages)return jsonify({"response": response_text})# 在后台线程中运行 HTTP APIthreading.Thread(target=lambda: flask_app.run(port=5000), daemon=True).start()# Telegram 机器人照常运行app = Application.builder().token(os.getenv("TELEGRAM_BOT_TOKEN")).build()app.add_handler(MessageHandler(filters.TEXT, handle_message))app.run_polling()
测试一下:
# 通过 Telegram你: 我叫 Rolly机器人: 很高兴认识你,Rolly!# 通过 HTTP——使用你的 Telegram 用户 ID,这样就能命中同一个会话curl -X POST http://127.0.0.1:5000/chat \-H "Content-Type: application/json" \-d '{"user_id": "YOUR_TELEGRAM_USER_ID", "message": "我叫什么名字?"}'{"response": "你叫 Rolly!"}
同一个 Agent,同一个会话,同一份记忆,两个界面。这就是网关。
OpenClaw 对 Telegram、Discord、WhatsApp、Slack、Signal、iMessage 等都是这样做的,全部通过一个配置文件搞定。
对话太长怎么办
聊了几周之后,会话文件变得很大,最终会触碰模型的 token 上限。怎么办?
把旧消息压缩成摘要,保留最近的:
def estimate_tokens(messages):"""粗略估算 token 数:约每 4 个字符 1 个 token"""return sum(len(json.dumps(m)) for m in messages) // 4def compact_session(user_id, messages):"""当上下文太长时压缩旧消息"""if estimate_tokens(messages) < 100_000: # 约 128k 窗口的 80%return messages # 不需要压缩split = len(messages) // 2old, recent = messages[:split], messages[split:]print(" 正在压缩会话历史...")summary = client.messages.create(model="claude-sonnet-4-5-20250929",max_tokens=2000,messages=[{"role": "user","content": ("简洁地总结这段对话。保留:\n""- 关于用户的关键事实(姓名、偏好)\n""- 重要决定\n""- 未完成的任务或待办事项\n\n"f"{json.dumps(old, indent=2)}")}])compacted = [{"role": "user","content": f"[之前的对话摘要]\n{summary.content[0].text}"}] + recentsave_session(user_id, compacted)return compacted
在处理器中加上这一行:
async def handle_message(update: Update, context):user_id = str(update.effective_user.id)messages = load_session(user_id)messages = compact_session(user_id, messages) # <-- 加这一行messages.append({"role": "user", "content": update.message.text})response_text, messages = run_agent_turn(messages, SOUL)save_session(user_id, messages)await update.message.reply_text(response_text)
机器人仍然记得重要信息,会话文件也保持在可管理的大小。
永不丢失的记忆
会话给你对话记忆。但如果你重置会话或开启新会话呢?一切都消失了。
我们需要长期记忆——永久保存的文件。
添加这些工具:
{"name": "save_memory","description": "将重要信息保存到长期记忆中。用于用户偏好、关键事实,以及任何值得跨会话记住的内容。","input_schema": {"type": "object","properties": {"key": {"type": "string","description": "简短标签,例如 'user-preferences'、'project-notes'"},"content": {"type": "string","description": "要记住的信息"}},"required": ["key", "content"]}},{"name": "memory_search","description": "在长期记忆中搜索相关信息。在对话开始时使用,以回忆之前会话的上下文。","input_schema": {"type": "object","properties": {"query": {"type": "string","description": "要搜索的内容"}},"required": ["query"]}}
实现它们:
MEMORY_DIR = "./memory"# 在 execute_tool 中添加这些分支:elif name == "save_memory":os.makedirs(MEMORY_DIR, exist_ok=True)filepath = os.path.join(MEMORY_DIR, f"{input['key']}.md")with open(filepath, "w") as f:f.write(input["content"])return f"已保存到记忆:{input['key']}"elif name == "memory_search":query = input["query"].lower()results = []if os.path.exists(MEMORY_DIR):for fname in os.listdir(MEMORY_DIR):if fname.endswith(".md"):with open(os.path.join(MEMORY_DIR, fname), "r") as f:content = f.read()if any(word in content.lower() for word in query.split()):results.append(f"--- {fname} ---\n{content}")return "\n\n".join(results) if results else "未找到匹配的记忆。"
更新你的 SOUL:
SOUL = """# 你是谁...现有个性...## 记忆你有一个长期记忆系统。- 使用 save_memory 存储重要信息(用户偏好、关键事实、项目详情)。- 在对话开始时使用 memory_search 回忆之前会话的上下文。记忆文件以 Markdown 格式存储在 ./memory/ 目录中。"""
试一试:
你: 记住我最喜欢的餐厅是 Elvies,我喜欢在周末去。
机器人: (使用 save_memory 写入 Rolly-profile.md) 好的——已保存你的餐厅偏好。
(重置会话或重启机器人)
你: 今晚去哪里吃饭?
机器人: (使用 memory_search 搜索”餐厅 晚餐 最爱”) 去 Elvies 怎么样?我知道那是你最喜欢的。这个周末去吗?
记忆之所以持久,是因为它存在文件里,而不是会话里。重启所有东西——记忆还在。
防止竞争条件
这里有一个隐藏的 bug:如果两条消息同时到达怎么办?
你在 Telegram 发”查一下我的日历”,同时通过 HTTP 发”今天天气怎么样”。两个请求都试图加载同一个会话,都试图写入它,最终数据就损坏了。
解决方法:每个会话一把锁。
# 加到你的机器人里from collections import defaultdictsession_locks = defaultdict(threading.Lock)
包装你的处理器:
async def handle_message(update: Update, context):user_id = str(update.effective_user.id)with session_locks[user_id]:messages = load_session(user_id)messages = compact_session(user_id, messages)messages.append({"role": "user", "content": update.message.text})response_text, messages = run_agent_turn(messages, SOUL)save_session(user_id, messages)await update.message.reply_text(response_text)
HTTP 端点也一样:
@flask_app.route("/chat", methods=["POST"])def chat():data = request.jsonuser_id = data["user_id"]with session_locks[user_id]:messages = load_session(user_id)messages = compact_session(user_id, messages)messages.append({"role": "user", "content": data["message"]})response_text, messages = run_agent_turn(messages, SOUL)save_session(user_id, messages)return jsonify({"response": response_text})
完成。同一个用户的消息排队处理,不同用户并行运行,不再有数据损坏。
让它自己醒来
现在你的 AI 只在你跟它说话时才工作。但如果你想让它每天早上检查你的邮件呢?或者提醒你开会呢?
你需要定时任务——心跳机制。
import scheduleimport timedef setup_heartbeats():"""配置周期性 Agent 任务"""def morning_briefing():print("\n⏰ 心跳:早间简报")# 使用独立的会话 key,避免 cron 污染主聊天session_key = "cron:morning-briefing"with session_locks[session_key]:messages = load_session(session_key)messages.append({"role": "user","content": "早上好!查一下今天的日期,给我一句励志名言。"})response_text, messages = run_agent_turn(messages, SOUL)save_session(session_key, messages)print(f" {response_text}\n")# 生产环境中,你还会把这条消息发到 Telegram/Discordschedule.every().day.at("07:30").do(morning_briefing)# 在后台线程中运行调度器def scheduler_loop():while True:schedule.run_pending()time.sleep(60)threading.Thread(target=scheduler_loop, daemon=True).start()# 在 run_polling() 之前调用setup_heartbeats()
关键点:每个心跳任务使用自己的会话 key(cron:morning-briefing),让定时任务和你的主聊天历史保持分离。
测试时,改成每分钟运行一次:
schedule.every(1).minutes.do(morning_briefing)
你会在终端看到它触发。测完改回每天一次。
多个 Agent
一个 Agent 很有用,但随着任务增多,一种个性无法胜任所有事情。研究助手需要不同的指令,和通用助手不一样。
解决方案:多个 Agent 配置加路由。
AGENTS = {"main": {"name": "Jarvis","soul": SOUL, # 我们现有的 SOUL"session_prefix": "agent:main",},"researcher": {"name": "Scout","soul": """你是 Scout,一位研究专家。你的工作:找到信息并注明来源。每一个论断都需要证据。使用工具收集数据。要全面但简洁。用 save_memory 保存重要发现,供其他 Agent 参考。""","session_prefix": "agent:researcher",},}def resolve_agent(message_text):"""根据前缀命令将消息路由到正确的 Agent"""if message_text.startswith("/research "):return "researcher", message_text[len("/research "):]return "main", message_text
更新你的处理器:
async def handle_message(update: Update, context):user_id = str(update.effective_user.id)agent_id, message_text = resolve_agent(update.message.text)agent = AGENTS[agent_id]session_key = f"{agent['session_prefix']}:{user_id}"with session_locks[session_key]:messages = load_session(session_key)messages = compact_session(session_key, messages)messages.append({"role": "user", "content": message_text})response_text, messages = run_agent_turn(messages, agent["soul"])save_session(session_key, messages)await update.message.reply_text(f"[{agent['name']}] {response_text}")
试一试:
你: 今天天气怎么样? [Jarvis] 天气不错!具体数据可以查一下天气服务。
你: /research Python 异步编程的最佳实践是什么? [Scout] 这是我找到的内容…… (使用 web_search、save_memory 收集并存储发现) 核心实践包括:1)使用 asyncio.gather 处理并发任务……
你: Scout 发现了哪些关于 Python 异步的内容? [Jarvis] (使用 memory_search) Scout 的研究发现,核心异步最佳实践包括……
每个 Agent 有自己的对话历史,但共享记忆。Scout 保存研究成果,Jarvis 之后可以搜索到。它们通过共享文件协同工作。
完整版代码
好了,把所有东西拼在一起。这是一个包含所有功能的完整可运行脚本:
#!/usr/bin/env python3# mini-openclaw.py - 一个极简的 OpenClaw 克隆# 运行方式:uv run --with anthropic --with schedule python mini-openclaw.pyimport anthropicimport subprocessimport jsonimport osimport reimport threadingimport timeimport schedulefrom collections import defaultdictfrom datetime import datetimeclient = anthropic.Anthropic()# ─── 配置 ───WORKSPACE = os.path.expanduser("~/.mini-openclaw")SESSIONS_DIR = os.path.join(WORKSPACE, "sessions")MEMORY_DIR = os.path.join(WORKSPACE, "memory")APPROVALS_FILE = os.path.join(WORKSPACE, "exec-approvals.json")# ─── Agents ───AGENTS = {"main": {"name": "Jarvis","model": "claude-sonnet-4-5-20250929","soul": ("你是 Jarvis,一位个人 AI 助手。\n""真正有帮助。省掉客套话。有自己的观点。\n""你有工具——主动使用它们。\n\n""## 记忆\n"f"你的工作区是 {WORKSPACE}。\n""使用 save_memory 跨会话存储重要信息。\n""在对话开始时使用 memory_search 回忆上下文。"),"session_prefix": "agent:main",},"researcher": {"name": "Scout","model": "claude-sonnet-4-5-20250929","soul": ("你是 Scout,一位研究专家。\n""你的工作:找到信息并注明来源。每个论断都需要证据。\n""使用工具收集数据。全面但简洁。\n""用 save_memory 保存重要发现,供其他 Agent 参考。"),"session_prefix": "agent:researcher",},}# ─── 工具 ───TOOLS = [{"name": "run_command","description": "运行一个 shell 命令","input_schema": {"type": "object","properties": {"command": {"type": "string", "description": "要运行的命令"}},"required": ["command"]}},{"name": "read_file","description": "从文件系统读取文件","input_schema": {"type": "object","properties": {"path": {"type": "string", "description": "文件路径"}},"required": ["path"]}},{"name": "write_file","description": "向文件写入内容(如需要会自动创建目录)","input_schema": {"type": "object","properties": {"path": {"type": "string", "description": "文件路径"},"content": {"type": "string", "description": "要写入的内容"}},"required": ["path", "content"]}},{"name": "save_memory","description": "将重要信息保存到长期记忆","input_schema": {"type": "object","properties": {"key": {"type": "string", "description": "简短标签(例如 'user-preferences')"},"content": {"type": "string", "description": "要记住的信息"}},"required": ["key", "content"]}},{"name": "memory_search","description": "在长期记忆中搜索相关信息","input_schema": {"type": "object","properties": {"query": {"type": "string", "description": "要搜索的内容"}},"required": ["query"]}},]# ─── 权限控制 ───SAFE_COMMANDS = {"ls", "cat", "head", "tail", "wc", "date", "whoami","echo", "pwd", "which", "git", "python", "node", "npm"}def load_approvals():if os.path.exists(APPROVALS_FILE):with open(APPROVALS_FILE) as f:return json.load(f)return {"allowed": [], "denied": []}def save_approval(command, approved):approvals = load_approvals()key = "allowed" if approved else "denied"if command not in approvals[key]:approvals[key].append(command)with open(APPROVALS_FILE, "w") as f:json.dump(approvals, f, indent=2)def check_command_safety(command):base_cmd = command.strip().split()[0] if command.strip() else ""if base_cmd in SAFE_COMMANDS:return "safe"approvals = load_approvals()if command in approvals["allowed"]:return "approved"return "needs_approval"# ─── 工具执行 ───def execute_tool(name, tool_input):if name == "run_command":cmd = tool_input["command"]safety = check_command_safety(cmd)if safety == "needs_approval":print(f"\n ⚠️ 命令: {cmd}")confirm = input(" 是否允许?(y/n): ").strip().lower()if confirm != "y":save_approval(cmd, False)return "用户拒绝了权限。"save_approval(cmd, True)try:result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)output = result.stdout + result.stderrreturn output if output else "(无输出)"except subprocess.TimeoutExpired:return "命令在 30 秒后超时"except Exception as e:return f"错误: {e}"elif name == "read_file":try:with open(tool_input["path"], "r") as f:return f.read()[:10000]except Exception as e:return f"错误: {e}"elif name == "write_file":try:os.makedirs(os.path.dirname(tool_input["path"]) or ".", exist_ok=True)with open(tool_input["path"], "w") as f:f.write(tool_input["content"])return f"已写入 {tool_input['path']}"except Exception as e:return f"错误: {e}"elif name == "save_memory":os.makedirs(MEMORY_DIR, exist_ok=True)filepath = os.path.join(MEMORY_DIR, f"{tool_input['key']}.md")with open(filepath, "w") as f:f.write(tool_input["content"])return f"已保存到记忆:{tool_input['key']}"elif name == "memory_search":query = tool_input["query"].lower()results = []if os.path.exists(MEMORY_DIR):for fname in os.listdir(MEMORY_DIR):if fname.endswith(".md"):with open(os.path.join(MEMORY_DIR, fname), "r") as f:content = f.read()if any(w in content.lower() for w in query.split()):results.append(f"--- {fname} ---\n{content}")return "\n\n".join(results) if results else "未找到匹配的记忆。"return f"未知工具:{name}"# ─── 会话管理 ───def get_session_path(session_key):os.makedirs(SESSIONS_DIR, exist_ok=True)safe_key = session_key.replace(":", "_").replace("/", "_")return os.path.join(SESSIONS_DIR, f"{safe_key}.jsonl")def load_session(session_key):path = get_session_path(session_key)messages = []if os.path.exists(path):with open(path, "r") as f:for line in f:if line.strip():try:messages.append(json.loads(line))except json.JSONDecodeError:continuereturn messagesdef append_message(session_key, message):with open(get_session_path(session_key), "a") as f:f.write(json.dumps(message) + "\n")def save_session(session_key, messages):with open(get_session_path(session_key), "w") as f:for msg in messages:f.write(json.dumps(msg) + "\n")# ─── 上下文压缩 ───def estimate_tokens(messages):return sum(len(json.dumps(m)) for m in messages) // 4def compact_session(session_key, messages):if estimate_tokens(messages) < 100_000:return messagessplit = len(messages) // 2old, recent = messages[:split], messages[split:]print("\n 正在压缩会话历史...")summary = client.messages.create(model="claude-sonnet-4-5-20250929",max_tokens=2000,messages=[{"role": "user","content": ("简洁地总结这段对话。保留关键事实、""决定和未完成任务:\n\n"f"{json.dumps(old, indent=2)}")}])compacted = [{"role": "user","content": f"[对话摘要]\n{summary.content[0].text}"}] + recentsave_session(session_key, compacted)return compacted# ─── 会话锁 ───session_locks = defaultdict(threading.Lock)# ─── Agent 循环 ───def serialize_content(content):serialized = []for block in content:if hasattr(block, "text"):serialized.append({"type": "text", "text": block.text})elif block.type == "tool_use":serialized.append({"type": "tool_use", "id": block.id,"name": block.name, "input": block.input})return serializeddef run_agent_turn(session_key, user_text, agent_config):"""运行一个完整的 Agent 轮次:加载会话、循环调用 LLM、保存。"""with session_locks[session_key]:messages = load_session(session_key)messages = compact_session(session_key, messages)user_msg = {"role": "user", "content": user_text}messages.append(user_msg)append_message(session_key, user_msg)for _ in range(20): # 最多工具调用轮次response = client.messages.create(model=agent_config["model"],max_tokens=4096,system=agent_config["soul"],tools=TOOLS,messages=messages)content = serialize_content(response.content)assistant_msg = {"role": "assistant", "content": content}messages.append(assistant_msg)append_message(session_key, assistant_msg)if response.stop_reason == "end_turn":return "".join(b.text for b in response.content if hasattr(b, "text"))if response.stop_reason == "tool_use":tool_results = []for block in response.content:if block.type == "tool_use":print(f" 🔧 {block.name}: {json.dumps(block.input)[:100]}")result = execute_tool(block.name, block.input)display = str(result)[:150]print(f" → {display}")tool_results.append({"type": "tool_result","tool_use_id": block.id,"content": str(result)})results_msg = {"role": "user", "content": tool_results}messages.append(results_msg)append_message(session_key, results_msg)return "(已达到最大轮次)"# ─── 多 Agent 路由 ───def resolve_agent(message_text):"""根据前缀命令将消息路由到正确的 Agent"""if message_text.startswith("/research "):return "researcher", message_text[len("/research "):]return "main", message_text# ─── 定时任务 / 心跳 ───def setup_heartbeats():def morning_check():print("\n⏰ 心跳:早间检查")result = run_agent_turn("cron:morning-check","早上好!查一下今天的日期,给我一句励志名言。",AGENTS["main"])print(f" {result}\n")schedule.every().day.at("07:30").do(morning_check)def scheduler_loop():while True:schedule.run_pending()time.sleep(60)threading.Thread(target=scheduler_loop, daemon=True).start()# ─── 交互式命令行 ───def main():for d in [WORKSPACE, SESSIONS_DIR, MEMORY_DIR]:os.makedirs(d, exist_ok=True)setup_heartbeats()session_key = "agent:main:repl"print("Mini OpenClaw")print(f" Agents: {', '.join(a['name'] for a in AGENTS.values())}")print(f" 工作区: {WORKSPACE}")print(" 命令: /new(重置)、/research <查询>、/quit\n")while True:try:user_input = input("你: ").strip()except (EOFError, KeyboardInterrupt):print("\n再见!")breakif not user_input:continueif user_input.lower() in ["/quit", "/exit", "/q"]:print("再见!")breakif user_input.lower() == "/new":session_key = f"agent:main:repl:{datetime.now().strftime('%Y%m%d%H%M%S')}"print(" 会话已重置。\n")continueagent_id, message_text = resolve_agent(user_input)agent_config = AGENTS[agent_id]sk = (f"{agent_config['session_prefix']}:repl"if agent_id != "main" else session_key)response = run_agent_turn(sk, message_text, agent_config)print(f"\n [{agent_config['name']}] {response}\n")if __name__ == "__main__":main()
保存为 mini-openclaw.py 并运行:
uv run --with anthropic --with schedule python mini-openclaw.py
运行效果如下:
Mini OpenClawAgents: Jarvis, Scout工作区: ~/.mini-openclaw命令: /new(重置)、/research <查询>、/quit你: 记住我最喜欢的餐厅是 Hai Cenato,我喜欢 7 点的预订🔧 save_memory: {"key": "user-preferences", "content": "最喜欢的餐厅...→ 已保存到记忆:user-preferences[Jarvis] 好的,已保存你的餐厅偏好——Hai Cenato,7 点预订。你: 我项目目录里有什么?🔧 run_command: {"command": "ls"}→ src, package.json, README.md, node_modules, ...[Jarvis] 你的项目是标准的 Node.js 结构,有 src/、package.json,还有那些常见的文件。你: /new会话已重置。你: 我喜欢在哪里吃饭?🔧 memory_search: {"query": "restaurant favorite food"}→ --- user-preferences.md ---最喜欢的餐厅:Hai Cenato...[Jarvis] 你喜欢 Hai Cenato,偏好 7 点的预订。你: /research AI Agent 的最新趋势是什么?🔧 web_search: {"query": "AI agent trends 2025"}→ AI agent trends 2025 的搜索结果🔧 save_memory: {"key": "research-ai-agents", ...}→ 已保存到记忆:research-ai-agents[Scout] 这是我找到的关于当前 AI Agent 趋势的内容……
我们做了什么
从零开始,我们构建了:
- 持久会话 — 崩溃后仍能保留的对话记忆
- SOUL.md — 让 AI 行为保持一致的个性文件
- 工具 + Agent 循环 — 让 AI 真正能做事情
- 权限控制 — 防止它把你的电脑搞崩
- 网关模式 — 一个 Agent,多个界面
- 上下文压缩 — 处理长对话
- 长期记忆 — 重置后仍能保留的知识
- 命令队列 — 防止竞争条件
- 心跳机制 — 定时任务
- 多 Agent — 不同任务用不同的 Agent
每一个组件都解决了一个真实问题。没有魔法,只是实际可行的解决方案。
更进一步
这个原型包含了核心架构。OpenClaw 在此基础上增加了生产级功能:
浏览器自动化 — 控制真实浏览器,使用语义快照(文本而非截图,token 消耗少得多)
会话作用域 — 配置会话是按用户、按频道还是共享
插件系统 — 添加新渠道而无需修改核心代码
向量搜索 — 基于 embedding 的语义记忆匹配
子 Agent — Agent 可以为特定任务生成子 Agent
但说真的,从简单开始。先让一个渠道跑起来,按需添加工具,会话不够用了再加记忆。复杂度来自你的需求,而不是照着某个蓝图硬套。
或者直接用 OpenClaw 也行,它是开源的,处理了很多边界情况。但现在你知道它的工作原理了。
(全文完)
