其实你自己就能做出 OpenClaw(教程在这)

说实话,我第一次听说 OpenClaw 的时候,以为这是什么只有博士级工程师才能搞懂的黑科技。结果呢?一旦拆开来看,其实挺简单的。

整个东西本质上就是一个网关——把 AI 连接到你的消息应用,给它一些操作电脑的工具,然后让它记住你是谁。就这样。难的地方在于,当你同时跑多个渠道时,怎么让它稳定运行。

我的做法是:从零开始,自己重新实现了核心组件。没有花哨的框架,就是一个消息 API、一个大模型,加上一些耐心。说真的,你也完全可以做到。


没人说的那个问题

浏览器里的 ChatGPT 和 Claude,说白了不太适合做真正的工作。别误会,它们确实很聪明。但是:

每次对话都从头开始。它不知道你叫什么、昨天做了什么,什么都不知道。你永远在重新介绍自己。

你得主动去找它。它不会主动来找你。想让它早上七点帮你检查日历?不行,它只在你坐在那儿打字的时候才工作。

它被困在一个文本框里。不能运行命令,不能帮你浏览网页,除了聊天什么实际的事都做不了。

它只活在一个标签页里。你的生活分散在 WhatsApp、Telegram、Discord、Slack 各处,但你的 AI?被困在自己的小窗口里。

如果有一个 AI 住在你真正在用的消息应用里,记得所有事情,能控制你的电脑,在你自己的硬件上 7×24 小时运行,那会怎样?

那就是 OpenClaw。我们来做一个。


从最简单的开始

首先,做一个能响应 Telegram 消息的 AI:

  1. # bot-v0.py - 最简单的 AI 机器人
  2. import os
  3. import anthropic
  4. from telegram import Update
  5. from telegram.ext import Application, MessageHandler, filters
  6. client = anthropic.Anthropic()
  7. async def handle_message(update: Update, context):
  8. user_message = update.message.text
  9. response = client.messages.create(
  10. model="claude-sonnet-4-5-20250929",
  11. max_tokens=1024,
  12. messages=[{"role": "user", "content": user_message}]
  13. )
  14. await update.message.reply_text(response.content[0].text)
  15. app = Application.builder().token(os.getenv("TELEGRAM_BOT_TOKEN")).build()
  16. app.add_handler(MessageHandler(filters.TEXT, handle_message))
  17. app.run_polling()

运行它,发一条消息,得到回复。挺酷的。但也完全没用,因为它没有任何记忆。问它”我刚才说了什么?”它完全不知道。


让它记住你

解决方法很简单——保存对话历史。我用的是 JSONL 文件,因为它崩溃安全。每行是一条消息,程序中途挂掉最多丢一行。

  1. # bot-v1.py - 带持久会话的机器人
  2. import json
  3. import os
  4. import anthropic
  5. from telegram import Update
  6. from telegram.ext import Application, MessageHandler, filters
  7. client = anthropic.Anthropic()
  8. SESSIONS_DIR = "./sessions"
  9. os.makedirs(SESSIONS_DIR, exist_ok=True)
  10. def get_session_path(user_id):
  11. return os.path.join(SESSIONS_DIR, f"{user_id}.jsonl")
  12. def load_session(user_id):
  13. """从磁盘加载对话历史"""
  14. path = get_session_path(user_id)
  15. messages = []
  16. if os.path.exists(path):
  17. with open(path, "r") as f:
  18. for line in f:
  19. if line.strip():
  20. messages.append(json.loads(line))
  21. return messages
  22. def append_to_session(user_id, message):
  23. """向会话文件追加一条消息"""
  24. path = get_session_path(user_id)
  25. with open(path, "a") as f:
  26. f.write(json.dumps(message) + "\n")
  27. def save_session(user_id, messages):
  28. """用完整消息列表覆写会话文件"""
  29. path = get_session_path(user_id)
  30. with open(path, "w") as f:
  31. for message in messages:
  32. f.write(json.dumps(message) + "\n")
  33. async def handle_message(update: Update, context):
  34. user_id = str(update.effective_user.id)
  35. user_message = update.message.text
  36. messages = load_session(user_id)
  37. user_msg = {"role": "user", "content": user_message}
  38. messages.append(user_msg)
  39. append_to_session(user_id, user_msg)
  40. response = client.messages.create(
  41. model="claude-sonnet-4-5-20250929",
  42. max_tokens=4096,
  43. messages=messages
  44. )
  45. assistant_msg = {"role": "assistant", "content": response.content[0].text}
  46. append_to_session(user_id, assistant_msg)
  47. await update.message.reply_text(response.content[0].text)
  48. app = Application.builder().token(os.getenv("TELEGRAM_BOT_TOKEN")).build()
  49. app.add_handler(MessageHandler(filters.TEXT, handle_message))
  50. app.run_polling()

现在你可以真正进行对话了:

你: 我叫 Rolly 机器人: 很高兴认识你,Rolly!

(几小时后……)

你: 我叫什么名字? 机器人: 你叫 Rolly!

这正是 OpenClaw 存储对话的方式:~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl。一个会话,一个文件。重启所有东西,数据还在。


赋予它个性

现在我们的机器人太泛泛了,无聊。用一个 SOUL 文件给它一些性格(对,OpenClaw 真的就叫这个名字):

  1. # bot-v2.py - 带个性的机器人
  2. SOUL = """
  3. # 你是谁
  4. **名字:** Jarvis
  5. **角色:** 个人 AI 助手
  6. ## 个性
  7. - 真正有帮助,而不是表演式的帮助
  8. - 省掉"好问题!"——直接帮忙就好
  9. - 有自己的观点,你可以不同意
  10. - 需要简洁时简洁,需要详细时详细
  11. ## 边界
  12. - 私密的事情保持私密
  13. - 有疑问时,在对外行动前先询问
  14. - 你不是用户的代言人——代替他们发送消息要谨慎
  15. ## 记忆
  16. 记住对话中的重要细节。
  17. 如果某件事值得记,就写下来。
  18. """
  19. async def handle_message(update: Update, context):
  20. user_id = str(update.effective_user.id)
  21. messages = load_session(user_id)
  22. user_msg = {"role": "user", "content": update.message.text}
  23. messages.append(user_msg)
  24. append_to_session(user_id, user_msg)
  25. response = client.messages.create(
  26. model="claude-sonnet-4-5-20250929",
  27. max_tokens=4096,
  28. system=SOUL, # <-- 个性在这里注入
  29. messages=messages
  30. )
  31. assistant_msg = {"role": "assistant", "content": response.content[0].text}
  32. append_to_session(user_id, assistant_msg)
  33. await update.message.reply_text(response.content[0].text)

SOUL 在每次调用时作为系统提示注入。现在你不是在和”通用 AI 助手 #47”对话,而是在和 Jarvis 对话。

在 OpenClaw 中,这个文件放在 ~/.openclaw/workspace/SOUL.md。想写什么都行,可以幽默,可以严肃,可以给它一个背景故事。越具体,它的行为就越一致。


真正做点事(工具)

一个只会聊天的机器人相当有限。如果它能运行命令、读取文件、搜索网页呢?

  1. import subprocess
  2. TOOLS = [
  3. {
  4. "name": "run_command",
  5. "description": "在用户的电脑上运行一个 shell 命令",
  6. "input_schema": {
  7. "type": "object",
  8. "properties": {
  9. "command": {"type": "string", "description": "要运行的命令"}
  10. },
  11. "required": ["command"]
  12. }
  13. },
  14. {
  15. "name": "read_file",
  16. "description": "从文件系统读取一个文件",
  17. "input_schema": {
  18. "type": "object",
  19. "properties": {
  20. "path": {"type": "string", "description": "文件路径"}
  21. },
  22. "required": ["path"]
  23. }
  24. },
  25. {
  26. "name": "write_file",
  27. "description": "向文件写入内容",
  28. "input_schema": {
  29. "type": "object",
  30. "properties": {
  31. "path": {"type": "string", "description": "文件路径"},
  32. "content": {"type": "string", "description": "要写入的内容"}
  33. },
  34. "required": ["path", "content"]
  35. }
  36. },
  37. {
  38. "name": "web_search",
  39. "description": "在网络上搜索信息",
  40. "input_schema": {
  41. "type": "object",
  42. "properties": {
  43. "query": {"type": "string", "description": "搜索关键词"}
  44. },
  45. "required": ["query"]
  46. }
  47. }
  48. ]
  49. def execute_tool(name, input):
  50. if name == "run_command":
  51. result = subprocess.run(
  52. input["command"], shell=True,
  53. capture_output=True, text=True, timeout=30
  54. )
  55. return result.stdout + result.stderr
  56. elif name == "read_file":
  57. with open(input["path"], "r") as f:
  58. return f.read()
  59. elif name == "write_file":
  60. with open(input["path"], "w") as f:
  61. f.write(input["content"])
  62. return f"已写入 {input['path']}"
  63. elif name == "web_search":
  64. # 简化版——实际使用时请接入真正的搜索 API
  65. return f"搜索结果:{input['query']}"
  66. return f"未知工具:{name}"

现在我们需要 Agent 循环。当 AI 想使用工具时,我们运行它并把结果返回:

  1. def serialize_content(content):
  2. """将 API 响应内容块转换为可 JSON 序列化的字典"""
  3. serialized = []
  4. for block in content:
  5. if hasattr(block, "text"):
  6. serialized.append({"type": "text", "text": block.text})
  7. elif block.type == "tool_use":
  8. serialized.append({
  9. "type": "tool_use",
  10. "id": block.id,
  11. "name": block.name,
  12. "input": block.input
  13. })
  14. return serialized
  15. def run_agent_turn(messages, system_prompt):
  16. """运行一个完整的 Agent 轮次(可能涉及多次工具调用)"""
  17. while True:
  18. response = client.messages.create(
  19. model="claude-sonnet-4-5-20250929",
  20. max_tokens=4096,
  21. system=system_prompt,
  22. tools=TOOLS,
  23. messages=messages
  24. )
  25. content = serialize_content(response.content)
  26. # 如果 AI 完成了(没有工具调用),返回文本
  27. if response.stop_reason == "end_turn":
  28. text = ""
  29. for block in response.content:
  30. if hasattr(block, "text"):
  31. text += block.text
  32. messages.append({"role": "assistant", "content": content})
  33. return text, messages
  34. # 处理工具调用
  35. if response.stop_reason == "tool_use":
  36. messages.append({"role": "assistant", "content": content})
  37. tool_results = []
  38. for block in response.content:
  39. if block.type == "tool_use":
  40. print(f" 工具: {block.name}({json.dumps(block.input)})")
  41. result = execute_tool(block.name, block.input)
  42. tool_results.append({
  43. "type": "tool_result",
  44. "tool_use_id": block.id,
  45. "content": str(result)
  46. })
  47. messages.append({"role": "user", "content": tool_results})

更新消息处理器:

  1. async def handle_message(update: Update, context):
  2. user_id = str(update.effective_user.id)
  3. messages = load_session(user_id)
  4. messages.append({"role": "user", "content": update.message.text})
  5. response_text, messages = run_agent_turn(messages, SOUL)
  6. save_session(user_id, messages)
  7. 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 / 怎么办?

我们需要权限控制:

  1. import re
  2. SAFE_COMMANDS = {"ls", "cat", "head", "tail", "wc", "date", "whoami", "echo"}
  3. DANGEROUS_PATTERNS = [r"\brm\b", r"\bsudo\b", r"\bchmod\b", r"\bcurl.*\|.*sh"]
  4. # 持久化白名单
  5. APPROVALS_FILE = "./exec-approvals.json"
  6. def load_approvals():
  7. if os.path.exists(APPROVALS_FILE):
  8. with open(APPROVALS_FILE) as f:
  9. return json.load(f)
  10. return {"allowed": [], "denied": []}
  11. def save_approval(command, approved):
  12. approvals = load_approvals()
  13. key = "allowed" if approved else "denied"
  14. if command not in approvals[key]:
  15. approvals[key].append(command)
  16. with open(APPROVALS_FILE, "w") as f:
  17. json.dump(approvals, f, indent=2)
  18. def check_command_safety(command):
  19. """返回 'safe'、'approved' 或 'needs_approval'"""
  20. base_cmd = command.strip().split()[0] if command.strip() else ""
  21. if base_cmd in SAFE_COMMANDS:
  22. return "safe"
  23. approvals = load_approvals()
  24. if command in approvals["allowed"]:
  25. return "approved"
  26. for pattern in DANGEROUS_PATTERNS:
  27. if re.search(pattern, command):
  28. return "needs_approval"
  29. return "needs_approval"

更新 run_command 的处理逻辑:

  1. if name == "run_command":
  2. cmd = input["command"]
  3. safety = check_command_safety(cmd)
  4. if safety == "needs_approval":
  5. # 实际的机器人中,你会通过 Telegram 提示用户并等待回复
  6. # 为简化起见,这里直接记录日志并拒绝
  7. print(f" ⚠ 已拦截: {cmd}(需要授权)")
  8. return "权限被拒绝。该命令需要授权。"
  9. result = subprocess.run(
  10. cmd, shell=True, capture_output=True,
  11. text=True, timeout=30
  12. )
  13. return result.stdout + result.stderr

安全命令立即执行。可疑命令被拦截。授权记录保存到 exec-approvals.json,同一条命令不会被问两次。


网关模式

这里开始变得有意思了。现在我们有了一个 Telegram 机器人,但 Discord 呢?WhatsApp 呢?Slack 呢?

你可以写多个独立的机器人。但那样的话对话和记忆就是分开的——Telegram 上的 AI 不知道你在 Discord 上聊了什么。

解决方法:一个统一的中央网关,处理所有渠道。注意,我们的 run_agent_turn 函数对 Telegram 一无所知,它只接收消息、返回文本。所以我们可以随意添加任何界面。

来证明一下,在 Telegram 旁边加一个 HTTP API:

  1. from flask import Flask, request, jsonify
  2. import threading
  3. flask_app = Flask(__name__)
  4. @flask_app.route("/chat", methods=["POST"])
  5. def chat():
  6. data = request.json
  7. user_id = data["user_id"]
  8. messages = load_session(user_id)
  9. messages.append({"role": "user", "content": data["message"]})
  10. response_text, messages = run_agent_turn(messages, SOUL)
  11. save_session(user_id, messages)
  12. return jsonify({"response": response_text})
  13. # 在后台线程中运行 HTTP API
  14. threading.Thread(target=lambda: flask_app.run(port=5000), daemon=True).start()
  15. # Telegram 机器人照常运行
  16. app = Application.builder().token(os.getenv("TELEGRAM_BOT_TOKEN")).build()
  17. app.add_handler(MessageHandler(filters.TEXT, handle_message))
  18. app.run_polling()

测试一下:

  1. # 通过 Telegram
  2. 你: 我叫 Rolly
  3. 机器人: 很高兴认识你,Rolly
  4. # 通过 HTTP——使用你的 Telegram 用户 ID,这样就能命中同一个会话
  5. curl -X POST http://127.0.0.1:5000/chat \
  6. -H "Content-Type: application/json" \
  7. -d '{"user_id": "YOUR_TELEGRAM_USER_ID", "message": "我叫什么名字?"}'
  8. {"response": "你叫 Rolly!"}

同一个 Agent,同一个会话,同一份记忆,两个界面。这就是网关。

OpenClaw 对 Telegram、Discord、WhatsApp、Slack、Signal、iMessage 等都是这样做的,全部通过一个配置文件搞定。


对话太长怎么办

聊了几周之后,会话文件变得很大,最终会触碰模型的 token 上限。怎么办?

把旧消息压缩成摘要,保留最近的:

  1. def estimate_tokens(messages):
  2. """粗略估算 token 数:约每 4 个字符 1 个 token"""
  3. return sum(len(json.dumps(m)) for m in messages) // 4
  4. def compact_session(user_id, messages):
  5. """当上下文太长时压缩旧消息"""
  6. if estimate_tokens(messages) < 100_000: # 约 128k 窗口的 80%
  7. return messages # 不需要压缩
  8. split = len(messages) // 2
  9. old, recent = messages[:split], messages[split:]
  10. print(" 正在压缩会话历史...")
  11. summary = client.messages.create(
  12. model="claude-sonnet-4-5-20250929",
  13. max_tokens=2000,
  14. messages=[{
  15. "role": "user",
  16. "content": (
  17. "简洁地总结这段对话。保留:\n"
  18. "- 关于用户的关键事实(姓名、偏好)\n"
  19. "- 重要决定\n"
  20. "- 未完成的任务或待办事项\n\n"
  21. f"{json.dumps(old, indent=2)}"
  22. )
  23. }]
  24. )
  25. compacted = [{
  26. "role": "user",
  27. "content": f"[之前的对话摘要]\n{summary.content[0].text}"
  28. }] + recent
  29. save_session(user_id, compacted)
  30. return compacted

在处理器中加上这一行:

  1. async def handle_message(update: Update, context):
  2. user_id = str(update.effective_user.id)
  3. messages = load_session(user_id)
  4. messages = compact_session(user_id, messages) # <-- 加这一行
  5. messages.append({"role": "user", "content": update.message.text})
  6. response_text, messages = run_agent_turn(messages, SOUL)
  7. save_session(user_id, messages)
  8. await update.message.reply_text(response_text)

机器人仍然记得重要信息,会话文件也保持在可管理的大小。


永不丢失的记忆

会话给你对话记忆。但如果你重置会话或开启新会话呢?一切都消失了。

我们需要长期记忆——永久保存的文件。

添加这些工具:

  1. {
  2. "name": "save_memory",
  3. "description": "将重要信息保存到长期记忆中。用于用户偏好、关键事实,以及任何值得跨会话记住的内容。",
  4. "input_schema": {
  5. "type": "object",
  6. "properties": {
  7. "key": {
  8. "type": "string",
  9. "description": "简短标签,例如 'user-preferences'、'project-notes'"
  10. },
  11. "content": {
  12. "type": "string",
  13. "description": "要记住的信息"
  14. }
  15. },
  16. "required": ["key", "content"]
  17. }
  18. },
  19. {
  20. "name": "memory_search",
  21. "description": "在长期记忆中搜索相关信息。在对话开始时使用,以回忆之前会话的上下文。",
  22. "input_schema": {
  23. "type": "object",
  24. "properties": {
  25. "query": {
  26. "type": "string",
  27. "description": "要搜索的内容"
  28. }
  29. },
  30. "required": ["query"]
  31. }
  32. }

实现它们:

  1. MEMORY_DIR = "./memory"
  2. # 在 execute_tool 中添加这些分支:
  3. elif name == "save_memory":
  4. os.makedirs(MEMORY_DIR, exist_ok=True)
  5. filepath = os.path.join(MEMORY_DIR, f"{input['key']}.md")
  6. with open(filepath, "w") as f:
  7. f.write(input["content"])
  8. return f"已保存到记忆:{input['key']}"
  9. elif name == "memory_search":
  10. query = input["query"].lower()
  11. results = []
  12. if os.path.exists(MEMORY_DIR):
  13. for fname in os.listdir(MEMORY_DIR):
  14. if fname.endswith(".md"):
  15. with open(os.path.join(MEMORY_DIR, fname), "r") as f:
  16. content = f.read()
  17. if any(word in content.lower() for word in query.split()):
  18. results.append(f"--- {fname} ---\n{content}")
  19. return "\n\n".join(results) if results else "未找到匹配的记忆。"

更新你的 SOUL:

  1. SOUL = """
  2. # 你是谁
  3. ...现有个性...
  4. ## 记忆
  5. 你有一个长期记忆系统。
  6. - 使用 save_memory 存储重要信息(用户偏好、关键事实、项目详情)。
  7. - 在对话开始时使用 memory_search 回忆之前会话的上下文。
  8. 记忆文件以 Markdown 格式存储在 ./memory/ 目录中。
  9. """

试一试:

你: 记住我最喜欢的餐厅是 Elvies,我喜欢在周末去。

机器人: (使用 save_memory 写入 Rolly-profile.md) 好的——已保存你的餐厅偏好。

(重置会话或重启机器人)

你: 今晚去哪里吃饭?

机器人: (使用 memory_search 搜索”餐厅 晚餐 最爱”) 去 Elvies 怎么样?我知道那是你最喜欢的。这个周末去吗?

记忆之所以持久,是因为它存在文件里,而不是会话里。重启所有东西——记忆还在。


防止竞争条件

这里有一个隐藏的 bug:如果两条消息同时到达怎么办?

你在 Telegram 发”查一下我的日历”,同时通过 HTTP 发”今天天气怎么样”。两个请求都试图加载同一个会话,都试图写入它,最终数据就损坏了。

解决方法:每个会话一把锁。

  1. # 加到你的机器人里
  2. from collections import defaultdict
  3. session_locks = defaultdict(threading.Lock)

包装你的处理器:

  1. async def handle_message(update: Update, context):
  2. user_id = str(update.effective_user.id)
  3. with session_locks[user_id]:
  4. messages = load_session(user_id)
  5. messages = compact_session(user_id, messages)
  6. messages.append({"role": "user", "content": update.message.text})
  7. response_text, messages = run_agent_turn(messages, SOUL)
  8. save_session(user_id, messages)
  9. await update.message.reply_text(response_text)

HTTP 端点也一样:

  1. @flask_app.route("/chat", methods=["POST"])
  2. def chat():
  3. data = request.json
  4. user_id = data["user_id"]
  5. with session_locks[user_id]:
  6. messages = load_session(user_id)
  7. messages = compact_session(user_id, messages)
  8. messages.append({"role": "user", "content": data["message"]})
  9. response_text, messages = run_agent_turn(messages, SOUL)
  10. save_session(user_id, messages)
  11. return jsonify({"response": response_text})

完成。同一个用户的消息排队处理,不同用户并行运行,不再有数据损坏。


让它自己醒来

现在你的 AI 只在你跟它说话时才工作。但如果你想让它每天早上检查你的邮件呢?或者提醒你开会呢?

你需要定时任务——心跳机制。

  1. import schedule
  2. import time
  3. def setup_heartbeats():
  4. """配置周期性 Agent 任务"""
  5. def morning_briefing():
  6. print("\n⏰ 心跳:早间简报")
  7. # 使用独立的会话 key,避免 cron 污染主聊天
  8. session_key = "cron:morning-briefing"
  9. with session_locks[session_key]:
  10. messages = load_session(session_key)
  11. messages.append({
  12. "role": "user",
  13. "content": "早上好!查一下今天的日期,给我一句励志名言。"
  14. })
  15. response_text, messages = run_agent_turn(messages, SOUL)
  16. save_session(session_key, messages)
  17. print(f" {response_text}\n")
  18. # 生产环境中,你还会把这条消息发到 Telegram/Discord
  19. schedule.every().day.at("07:30").do(morning_briefing)
  20. # 在后台线程中运行调度器
  21. def scheduler_loop():
  22. while True:
  23. schedule.run_pending()
  24. time.sleep(60)
  25. threading.Thread(target=scheduler_loop, daemon=True).start()
  26. # 在 run_polling() 之前调用
  27. setup_heartbeats()

关键点:每个心跳任务使用自己的会话 key(cron:morning-briefing),让定时任务和你的主聊天历史保持分离。

测试时,改成每分钟运行一次:

  1. schedule.every(1).minutes.do(morning_briefing)

你会在终端看到它触发。测完改回每天一次。


多个 Agent

一个 Agent 很有用,但随着任务增多,一种个性无法胜任所有事情。研究助手需要不同的指令,和通用助手不一样。

解决方案:多个 Agent 配置加路由。

  1. AGENTS = {
  2. "main": {
  3. "name": "Jarvis",
  4. "soul": SOUL, # 我们现有的 SOUL
  5. "session_prefix": "agent:main",
  6. },
  7. "researcher": {
  8. "name": "Scout",
  9. "soul": """你是 Scout,一位研究专家。
  10. 你的工作:找到信息并注明来源。每一个论断都需要证据。
  11. 使用工具收集数据。要全面但简洁。
  12. 用 save_memory 保存重要发现,供其他 Agent 参考。""",
  13. "session_prefix": "agent:researcher",
  14. },
  15. }
  16. def resolve_agent(message_text):
  17. """根据前缀命令将消息路由到正确的 Agent"""
  18. if message_text.startswith("/research "):
  19. return "researcher", message_text[len("/research "):]
  20. return "main", message_text

更新你的处理器:

  1. async def handle_message(update: Update, context):
  2. user_id = str(update.effective_user.id)
  3. agent_id, message_text = resolve_agent(update.message.text)
  4. agent = AGENTS[agent_id]
  5. session_key = f"{agent['session_prefix']}:{user_id}"
  6. with session_locks[session_key]:
  7. messages = load_session(session_key)
  8. messages = compact_session(session_key, messages)
  9. messages.append({"role": "user", "content": message_text})
  10. response_text, messages = run_agent_turn(messages, agent["soul"])
  11. save_session(session_key, messages)
  12. 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 之后可以搜索到。它们通过共享文件协同工作。


完整版代码

好了,把所有东西拼在一起。这是一个包含所有功能的完整可运行脚本:

  1. #!/usr/bin/env python3
  2. # mini-openclaw.py - 一个极简的 OpenClaw 克隆
  3. # 运行方式:uv run --with anthropic --with schedule python mini-openclaw.py
  4. import anthropic
  5. import subprocess
  6. import json
  7. import os
  8. import re
  9. import threading
  10. import time
  11. import schedule
  12. from collections import defaultdict
  13. from datetime import datetime
  14. client = anthropic.Anthropic()
  15. # ─── 配置 ───
  16. WORKSPACE = os.path.expanduser("~/.mini-openclaw")
  17. SESSIONS_DIR = os.path.join(WORKSPACE, "sessions")
  18. MEMORY_DIR = os.path.join(WORKSPACE, "memory")
  19. APPROVALS_FILE = os.path.join(WORKSPACE, "exec-approvals.json")
  20. # ─── Agents ───
  21. AGENTS = {
  22. "main": {
  23. "name": "Jarvis",
  24. "model": "claude-sonnet-4-5-20250929",
  25. "soul": (
  26. "你是 Jarvis,一位个人 AI 助手。\n"
  27. "真正有帮助。省掉客套话。有自己的观点。\n"
  28. "你有工具——主动使用它们。\n\n"
  29. "## 记忆\n"
  30. f"你的工作区是 {WORKSPACE}。\n"
  31. "使用 save_memory 跨会话存储重要信息。\n"
  32. "在对话开始时使用 memory_search 回忆上下文。"
  33. ),
  34. "session_prefix": "agent:main",
  35. },
  36. "researcher": {
  37. "name": "Scout",
  38. "model": "claude-sonnet-4-5-20250929",
  39. "soul": (
  40. "你是 Scout,一位研究专家。\n"
  41. "你的工作:找到信息并注明来源。每个论断都需要证据。\n"
  42. "使用工具收集数据。全面但简洁。\n"
  43. "用 save_memory 保存重要发现,供其他 Agent 参考。"
  44. ),
  45. "session_prefix": "agent:researcher",
  46. },
  47. }
  48. # ─── 工具 ───
  49. TOOLS = [
  50. {
  51. "name": "run_command",
  52. "description": "运行一个 shell 命令",
  53. "input_schema": {
  54. "type": "object",
  55. "properties": {
  56. "command": {"type": "string", "description": "要运行的命令"}
  57. },
  58. "required": ["command"]
  59. }
  60. },
  61. {
  62. "name": "read_file",
  63. "description": "从文件系统读取文件",
  64. "input_schema": {
  65. "type": "object",
  66. "properties": {
  67. "path": {"type": "string", "description": "文件路径"}
  68. },
  69. "required": ["path"]
  70. }
  71. },
  72. {
  73. "name": "write_file",
  74. "description": "向文件写入内容(如需要会自动创建目录)",
  75. "input_schema": {
  76. "type": "object",
  77. "properties": {
  78. "path": {"type": "string", "description": "文件路径"},
  79. "content": {"type": "string", "description": "要写入的内容"}
  80. },
  81. "required": ["path", "content"]
  82. }
  83. },
  84. {
  85. "name": "save_memory",
  86. "description": "将重要信息保存到长期记忆",
  87. "input_schema": {
  88. "type": "object",
  89. "properties": {
  90. "key": {"type": "string", "description": "简短标签(例如 'user-preferences')"},
  91. "content": {"type": "string", "description": "要记住的信息"}
  92. },
  93. "required": ["key", "content"]
  94. }
  95. },
  96. {
  97. "name": "memory_search",
  98. "description": "在长期记忆中搜索相关信息",
  99. "input_schema": {
  100. "type": "object",
  101. "properties": {
  102. "query": {"type": "string", "description": "要搜索的内容"}
  103. },
  104. "required": ["query"]
  105. }
  106. },
  107. ]
  108. # ─── 权限控制 ───
  109. SAFE_COMMANDS = {"ls", "cat", "head", "tail", "wc", "date", "whoami",
  110. "echo", "pwd", "which", "git", "python", "node", "npm"}
  111. def load_approvals():
  112. if os.path.exists(APPROVALS_FILE):
  113. with open(APPROVALS_FILE) as f:
  114. return json.load(f)
  115. return {"allowed": [], "denied": []}
  116. def save_approval(command, approved):
  117. approvals = load_approvals()
  118. key = "allowed" if approved else "denied"
  119. if command not in approvals[key]:
  120. approvals[key].append(command)
  121. with open(APPROVALS_FILE, "w") as f:
  122. json.dump(approvals, f, indent=2)
  123. def check_command_safety(command):
  124. base_cmd = command.strip().split()[0] if command.strip() else ""
  125. if base_cmd in SAFE_COMMANDS:
  126. return "safe"
  127. approvals = load_approvals()
  128. if command in approvals["allowed"]:
  129. return "approved"
  130. return "needs_approval"
  131. # ─── 工具执行 ───
  132. def execute_tool(name, tool_input):
  133. if name == "run_command":
  134. cmd = tool_input["command"]
  135. safety = check_command_safety(cmd)
  136. if safety == "needs_approval":
  137. print(f"\n ⚠️ 命令: {cmd}")
  138. confirm = input(" 是否允许?(y/n): ").strip().lower()
  139. if confirm != "y":
  140. save_approval(cmd, False)
  141. return "用户拒绝了权限。"
  142. save_approval(cmd, True)
  143. try:
  144. result = subprocess.run(
  145. cmd, shell=True, capture_output=True, text=True, timeout=30
  146. )
  147. output = result.stdout + result.stderr
  148. return output if output else "(无输出)"
  149. except subprocess.TimeoutExpired:
  150. return "命令在 30 秒后超时"
  151. except Exception as e:
  152. return f"错误: {e}"
  153. elif name == "read_file":
  154. try:
  155. with open(tool_input["path"], "r") as f:
  156. return f.read()[:10000]
  157. except Exception as e:
  158. return f"错误: {e}"
  159. elif name == "write_file":
  160. try:
  161. os.makedirs(os.path.dirname(tool_input["path"]) or ".", exist_ok=True)
  162. with open(tool_input["path"], "w") as f:
  163. f.write(tool_input["content"])
  164. return f"已写入 {tool_input['path']}"
  165. except Exception as e:
  166. return f"错误: {e}"
  167. elif name == "save_memory":
  168. os.makedirs(MEMORY_DIR, exist_ok=True)
  169. filepath = os.path.join(MEMORY_DIR, f"{tool_input['key']}.md")
  170. with open(filepath, "w") as f:
  171. f.write(tool_input["content"])
  172. return f"已保存到记忆:{tool_input['key']}"
  173. elif name == "memory_search":
  174. query = tool_input["query"].lower()
  175. results = []
  176. if os.path.exists(MEMORY_DIR):
  177. for fname in os.listdir(MEMORY_DIR):
  178. if fname.endswith(".md"):
  179. with open(os.path.join(MEMORY_DIR, fname), "r") as f:
  180. content = f.read()
  181. if any(w in content.lower() for w in query.split()):
  182. results.append(f"--- {fname} ---\n{content}")
  183. return "\n\n".join(results) if results else "未找到匹配的记忆。"
  184. return f"未知工具:{name}"
  185. # ─── 会话管理 ───
  186. def get_session_path(session_key):
  187. os.makedirs(SESSIONS_DIR, exist_ok=True)
  188. safe_key = session_key.replace(":", "_").replace("/", "_")
  189. return os.path.join(SESSIONS_DIR, f"{safe_key}.jsonl")
  190. def load_session(session_key):
  191. path = get_session_path(session_key)
  192. messages = []
  193. if os.path.exists(path):
  194. with open(path, "r") as f:
  195. for line in f:
  196. if line.strip():
  197. try:
  198. messages.append(json.loads(line))
  199. except json.JSONDecodeError:
  200. continue
  201. return messages
  202. def append_message(session_key, message):
  203. with open(get_session_path(session_key), "a") as f:
  204. f.write(json.dumps(message) + "\n")
  205. def save_session(session_key, messages):
  206. with open(get_session_path(session_key), "w") as f:
  207. for msg in messages:
  208. f.write(json.dumps(msg) + "\n")
  209. # ─── 上下文压缩 ───
  210. def estimate_tokens(messages):
  211. return sum(len(json.dumps(m)) for m in messages) // 4
  212. def compact_session(session_key, messages):
  213. if estimate_tokens(messages) < 100_000:
  214. return messages
  215. split = len(messages) // 2
  216. old, recent = messages[:split], messages[split:]
  217. print("\n 正在压缩会话历史...")
  218. summary = client.messages.create(
  219. model="claude-sonnet-4-5-20250929",
  220. max_tokens=2000,
  221. messages=[{
  222. "role": "user",
  223. "content": (
  224. "简洁地总结这段对话。保留关键事实、"
  225. "决定和未完成任务:\n\n"
  226. f"{json.dumps(old, indent=2)}"
  227. )
  228. }]
  229. )
  230. compacted = [{
  231. "role": "user",
  232. "content": f"[对话摘要]\n{summary.content[0].text}"
  233. }] + recent
  234. save_session(session_key, compacted)
  235. return compacted
  236. # ─── 会话锁 ───
  237. session_locks = defaultdict(threading.Lock)
  238. # ─── Agent 循环 ───
  239. def serialize_content(content):
  240. serialized = []
  241. for block in content:
  242. if hasattr(block, "text"):
  243. serialized.append({"type": "text", "text": block.text})
  244. elif block.type == "tool_use":
  245. serialized.append({
  246. "type": "tool_use", "id": block.id,
  247. "name": block.name, "input": block.input
  248. })
  249. return serialized
  250. def run_agent_turn(session_key, user_text, agent_config):
  251. """运行一个完整的 Agent 轮次:加载会话、循环调用 LLM、保存。"""
  252. with session_locks[session_key]:
  253. messages = load_session(session_key)
  254. messages = compact_session(session_key, messages)
  255. user_msg = {"role": "user", "content": user_text}
  256. messages.append(user_msg)
  257. append_message(session_key, user_msg)
  258. for _ in range(20): # 最多工具调用轮次
  259. response = client.messages.create(
  260. model=agent_config["model"],
  261. max_tokens=4096,
  262. system=agent_config["soul"],
  263. tools=TOOLS,
  264. messages=messages
  265. )
  266. content = serialize_content(response.content)
  267. assistant_msg = {"role": "assistant", "content": content}
  268. messages.append(assistant_msg)
  269. append_message(session_key, assistant_msg)
  270. if response.stop_reason == "end_turn":
  271. return "".join(
  272. b.text for b in response.content if hasattr(b, "text")
  273. )
  274. if response.stop_reason == "tool_use":
  275. tool_results = []
  276. for block in response.content:
  277. if block.type == "tool_use":
  278. print(f" 🔧 {block.name}: {json.dumps(block.input)[:100]}")
  279. result = execute_tool(block.name, block.input)
  280. display = str(result)[:150]
  281. print(f" → {display}")
  282. tool_results.append({
  283. "type": "tool_result",
  284. "tool_use_id": block.id,
  285. "content": str(result)
  286. })
  287. results_msg = {"role": "user", "content": tool_results}
  288. messages.append(results_msg)
  289. append_message(session_key, results_msg)
  290. return "(已达到最大轮次)"
  291. # ─── 多 Agent 路由 ───
  292. def resolve_agent(message_text):
  293. """根据前缀命令将消息路由到正确的 Agent"""
  294. if message_text.startswith("/research "):
  295. return "researcher", message_text[len("/research "):]
  296. return "main", message_text
  297. # ─── 定时任务 / 心跳 ───
  298. def setup_heartbeats():
  299. def morning_check():
  300. print("\n⏰ 心跳:早间检查")
  301. result = run_agent_turn(
  302. "cron:morning-check",
  303. "早上好!查一下今天的日期,给我一句励志名言。",
  304. AGENTS["main"]
  305. )
  306. print(f" {result}\n")
  307. schedule.every().day.at("07:30").do(morning_check)
  308. def scheduler_loop():
  309. while True:
  310. schedule.run_pending()
  311. time.sleep(60)
  312. threading.Thread(target=scheduler_loop, daemon=True).start()
  313. # ─── 交互式命令行 ───
  314. def main():
  315. for d in [WORKSPACE, SESSIONS_DIR, MEMORY_DIR]:
  316. os.makedirs(d, exist_ok=True)
  317. setup_heartbeats()
  318. session_key = "agent:main:repl"
  319. print("Mini OpenClaw")
  320. print(f" Agents: {', '.join(a['name'] for a in AGENTS.values())}")
  321. print(f" 工作区: {WORKSPACE}")
  322. print(" 命令: /new(重置)、/research <查询>、/quit\n")
  323. while True:
  324. try:
  325. user_input = input("你: ").strip()
  326. except (EOFError, KeyboardInterrupt):
  327. print("\n再见!")
  328. break
  329. if not user_input:
  330. continue
  331. if user_input.lower() in ["/quit", "/exit", "/q"]:
  332. print("再见!")
  333. break
  334. if user_input.lower() == "/new":
  335. session_key = f"agent:main:repl:{datetime.now().strftime('%Y%m%d%H%M%S')}"
  336. print(" 会话已重置。\n")
  337. continue
  338. agent_id, message_text = resolve_agent(user_input)
  339. agent_config = AGENTS[agent_id]
  340. sk = (
  341. f"{agent_config['session_prefix']}:repl"
  342. if agent_id != "main" else session_key
  343. )
  344. response = run_agent_turn(sk, message_text, agent_config)
  345. print(f"\n [{agent_config['name']}] {response}\n")
  346. if __name__ == "__main__":
  347. main()

保存为 mini-openclaw.py 并运行:

  1. uv run --with anthropic --with schedule python mini-openclaw.py

运行效果如下:

  1. Mini OpenClaw
  2. Agents: Jarvis, Scout
  3. 工作区: ~/.mini-openclaw
  4. 命令: /new(重置)、/research <查询>、/quit
  5. 你: 记住我最喜欢的餐厅是 Hai Cenato,我喜欢 7 点的预订
  6. 🔧 save_memory: {"key": "user-preferences", "content": "最喜欢的餐厅...
  7. → 已保存到记忆:user-preferences
  8. [Jarvis] 好的,已保存你的餐厅偏好——Hai Cenato,7 点预订。
  9. 你: 我项目目录里有什么?
  10. 🔧 run_command: {"command": "ls"}
  11. → src, package.json, README.md, node_modules, ...
  12. [Jarvis] 你的项目是标准的 Node.js 结构,有 src/、package.json,还有那些常见的文件。
  13. 你: /new
  14. 会话已重置。
  15. 你: 我喜欢在哪里吃饭?
  16. 🔧 memory_search: {"query": "restaurant favorite food"}
  17. → --- user-preferences.md ---
  18. 最喜欢的餐厅:Hai Cenato...
  19. [Jarvis] 你喜欢 Hai Cenato,偏好 7 点的预订。
  20. 你: /research AI Agent 的最新趋势是什么?
  21. 🔧 web_search: {"query": "AI agent trends 2025"}
  22. → AI agent trends 2025 的搜索结果
  23. 🔧 save_memory: {"key": "research-ai-agents", ...}
  24. → 已保存到记忆:research-ai-agents
  25. [Scout] 这是我找到的关于当前 AI Agent 趋势的内容……

我们做了什么

从零开始,我们构建了:

  • 持久会话 — 崩溃后仍能保留的对话记忆
  • SOUL.md — 让 AI 行为保持一致的个性文件
  • 工具 + Agent 循环 — 让 AI 真正能做事情
  • 权限控制 — 防止它把你的电脑搞崩
  • 网关模式 — 一个 Agent,多个界面
  • 上下文压缩 — 处理长对话
  • 长期记忆 — 重置后仍能保留的知识
  • 命令队列 — 防止竞争条件
  • 心跳机制 — 定时任务
  • 多 Agent — 不同任务用不同的 Agent

每一个组件都解决了一个真实问题。没有魔法,只是实际可行的解决方案。


更进一步

这个原型包含了核心架构。OpenClaw 在此基础上增加了生产级功能:

浏览器自动化 — 控制真实浏览器,使用语义快照(文本而非截图,token 消耗少得多)

会话作用域 — 配置会话是按用户、按频道还是共享

插件系统 — 添加新渠道而无需修改核心代码

向量搜索 — 基于 embedding 的语义记忆匹配

子 Agent — Agent 可以为特定任务生成子 Agent

但说真的,从简单开始。先让一个渠道跑起来,按需添加工具,会话不够用了再加记忆。复杂度来自你的需求,而不是照着某个蓝图硬套。

或者直接用 OpenClaw 也行,它是开源的,处理了很多边界情况。但现在你知道它的工作原理了。


(全文完)