如何用400行代码构建OpenClaw

你可以用400行代码构建一个行为类似于OpenClaw的智能体。只需使用TypeScript、Anthropic SDK、Slack SDK和一个YAML解析库。无需框架,也无需复杂的抽象------只需在一个脚本中包含几个函数。

截至2026年2月19日,OpenClaw的代码库拥有超过50万行TypeScript代码。但其核心可以简化为一个非常短小精悍的智能体:它在Slack中响应,使用技能,跨对话记住事实,浏览计算机上的文件,执行命令,访问互联网,并自主行动------无需人工干预。就像OpenClaw一样。

在这篇文章中,你将了解它的内部工作原理。我将引导你从零开始构建一个类似OpenClaw的智能体,这样你就能更好地理解你可能已经在使用的工具------并将这些想法应用到你自己的智能体系统中。

我们将构建什么,不构建什么

我们不会重现完整的OpenClaw体验。这篇博客文章的目标是阐明OpenClaw背后的核心原则,而不是精确地重现它。我们不会构建Web界面、Telegram和WhatsApp集成、语音支持或其他生活质量功能。

然而,我们将构建一个功能齐全的智能体,它能够:

  • 响应来自授权用户的Slack私信
  • 使用计算机
  • 访问互联网
  • 跨对话保持记忆
  • 使用智能体技能
  • 学习用户的偏好
  • 无需明确提示即可主动行动

这篇博客文章旨在让你能够跟着一起构建智能体。每个部分都会添加一个新功能,并在前一个功能的基础上进行构建,这样你就可以逐步看到和使用智能体的演变。

接收Slack消息

让我们从创建一个简单的脚本开始,它接收来自Slack的消息并进行回复。

初始化一个新的TypeScript项目。这里,我们将使用Bun

bash 复制代码
bun init

安装Slack SDK:

bash 复制代码
bun add @slack/bolt

这是完整的逻辑。将其保存到一个名为index.ts的文件中。

typescript 复制代码
import { App } from "@slack/bolt";

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
});

app.event("message", async ({ event, client }) => {
  console.log("Received message event at", new Date().toLocaleString());
  if (event.subtype || event.channel_type !== "im") {
    return;
  }
  await client.chat.postMessage({
    channel: event.channel,
    thread_ts: event.thread_ts ?? event.ts,
    text: `Hey! Your Slack user id is \`${event.user}\` - you'll need it later.`,
  });
});

console.log("Slack agent running");
app.start();

你需要创建一个Slack应用来运行它:

  1. 最简单的方法是访问这个链接:它预先填写了创建新应用所需的所有权限,以便与机器人交互。该链接是使用这个脚本生成的。
  2. 创建后,在"基本信息"页面上生成一个具有connections:write范围的应用级令牌。Slack会要求你为其命名------任何名称都可以。该令牌将是SLACK_APP_TOKEN环境变量的值。
  3. 然后,通过访问"安装应用"页面并将应用安装到工作区来生成SLACK_BOT_TOKEN环境变量。

现在使用以下命令运行机器人:

bash 复制代码
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... bun run index.ts

在Slack中找到它。它会在搜索栏中显示为"picobot"。向它发送一条私信,它应该会回复。

作为LLM回复

现在让我们使用LLM生成回复。我们将使用Anthropic的SDK:

bash 复制代码
bun add @anthropic-ai/sdk

LLM将能够通过工具调用发送Slack消息:

typescript 复制代码
import Anthropic from "@anthropic-ai/sdk";

type ToolWithExecute = Anthropic.Tool & {
  execute: (input: any) => Promise<any>;
};

function createTools(channel: string, threadTs: string): ToolWithExecute[] {
  return [
    {
      name: "send_slack_message",
      description:
        "Send a message to the user in Slack. This is the only way to communicate with the user.",
      input_schema: {
        type: "object",
        properties: {
          text: {
            type: "string",
            description: "The message text (supports Slack mrkdwn formatting)",
          },
        },
        required: ["text"],
      },
      execute: async (input: { text: string }) => {
        await app.client.chat.postMessage({
          channel,
          thread_ts: threadTs,
          text: input.text,
          blocks: [
            {
              type: "markdown",
              text: input.text,
            },
          ],
        });
        return "Message sent.";
      },
    },
  ];
}

我们将创建一个函数,该函数调用Anthropic的API来生成响应,如果响应包含工具调用,则执行工具调用。

typescript 复制代码
async function generateMessages(args: {
  channel: string;
  threadTs: string;
  system: string;
  messages: Anthropic.MessageParam[];
}): Promise<Anthropic.MessageParam[]> {
  const { channel, threadTs, system, messages } = args;
  const tools = createTools(channel, threadTs);

  console.log("Generating messages for thread", threadTs);
  const response = await anthropic.messages.create({
    model: "claude-opus-4-6",
    max_tokens: 8096,
    system,
    messages,
    tools,
  });
  console.log(
    `Response generated for thread ${threadTs}: ${response.usage.output_tokens} tokens`,
  );

  const toolsByName = new Map(tools.map((t) => [t.name, t]));
  const toolResults: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== "tool_use") {
      continue;
    }
    try {
      console.log(`Agent used tool ${block.name}`);
      const tool = toolsByName.get(block.name);
      if (!tool) {
        throw new Error(`tool "${block.name}" not found`);
      }
      const result = await tool.execute(block.input);
      toolResults.push({
        type: "tool_result",
        tool_use_id: block.id,
        content: typeof result === "string" ? result : JSON.stringify(result),
      });
    } catch (e: any) {
      console.warn(`Agent tried to use tool ${block.name} but failed`, e);
      toolResults.push({
        type: "tool_result",
        tool_use_id: block.id,
        content: `Error: ${e.message}`,
        is_error: true,
      });
    }
  }

  messages.push({ role: "assistant", content: response.content });
  if (toolResults.length > 0) {
    messages.push({
      role: "user",
      content: toolResults,
    });
  }

  return messages;
}

最后,我们将在消息事件处理程序中调用generateMessages

typescript 复制代码
app.event("message", async ({ event }) => {
  if (event.subtype || event.channel_type !== "im") {
    return;
  }
  const threadTs = event.thread_ts ?? event.ts;
  const channel = event.channel;

  // Only allow authorized users to interact with the bot
  if (event.user !== process.env.SLACK_USER_ID) {
    await app.client.chat.postMessage({
      channel,
      thread_ts: threadTs,
      text: `I'm sorry, I'm not authorized to respond to messages from you. Set the \`SLACK_USER_ID\` environment variable to \`${event.user}\` to allow me to respond to your messages.`,
    });
    return;
  }

  // Show a typing indicator to the user while we generate the response
  // It'll be auto-cleared once the agent sends a Slack message
  await app.client.assistant.threads.setStatus({
    channel_id: channel,
    thread_ts: threadTs,
    status: "is typing...",
  });

  await generateMessages({
    channel: event.channel,
    threadTs,
    system: "You are a helpful Slack assistant.",
    messages: [
      {
        role: "user",
        content: `User <@${event.user}> sent this message (timestamp: ${event.ts}) in Slack:\n\`\`\`\n${event.text}\n\`\`\`\n\nYou must respond using the \`send_slack_message\` tool.`,
      },
    ],
  });
});

这是目前为止的完整代码------你可以将其保存到index.ts中。

LLM现在可以响应来自授权用户的Slack消息。请记住在运行机器人之前设置SLACK_USER_ID环境变量。

bash 复制代码
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... SLACK_USER_ID=U... bun run index.ts

跟踪对话

机器人会响应消息,但它不记得对话内容。

让我们改变这一点。我们将对话历史记录持久化到~/.picobot/threads/中的JSON文件中。每个文件都将以线程时间戳命名,并包含线程的消息。当收到新的Slack消息时,我们将加载线程并将新消息添加到其中。

typescript 复制代码
import fs from "node:fs";
import path from "node:path";
import os from "node:os";

const configDir = path.resolve(os.homedir(), ".picobot");
const threadsDir = path.resolve(configDir, "threads");

interface Thread {
  threadTs: string;
  channel: string;
  messages: Anthropic.MessageParam[];
}

function saveThread(threadTs: string, thread: Thread): void {
  fs.mkdirSync(threadsDir, { recursive: true });
  return fs.writeFileSync(
    path.resolve(threadsDir, `${threadTs}.json`),
    JSON.stringify(thread, null, 2),
  );
}

function loadThread(threadTs: string): Thread | undefined {
  try {
    return JSON.parse(
      fs.readFileSync(path.resolve(threadsDir, `${threadTs}.json`), "utf-8"),
    );
  } catch (e) {
    return undefined;
  }
}

app.event("message", async ({ event }) => {
  // ... existing code up to the status indicator ...

  const thread: Thread = loadThread(threadTs) ?? {
    threadTs,
    channel,
    messages: [],
  };
  const messages = await generateMessages({
    channel: event.channel,
    threadTs,
    system: "You are a helpful Slack assistant.",
    messages: [
      ...thread.messages,
      {
        role: "user",
        content: `User <@${event.user}> sent this message (timestamp: ${event.ts}) in Slack:\n\`\`\`\n${event.text}\n\`\`\`\n\nYou must respond using the \`send_slack_message\` tool.`,
      },
    ],
  });
  saveThread(threadTs, {
    ...thread,
    messages,
  });
});

这是目前为止的完整代码。

机器人现在会记住对话内容:

记忆压缩

现在,我们的机器人会记住对话内容,但它会记住所有内容。如果对话持续足够长的时间,它最终会超出LLM的上下文窗口。当这种情况发生时,LLM会开始"失忆",并且无法再引用旧的对话内容。

为了解决这个问题,我们将实现记忆压缩。当对话历史记录变得太长时,我们将要求LLM将其压缩成一个简短的摘要。然后,我们将用摘要替换旧的对话内容,从而为新的对话腾出空间。

typescript 复制代码
// ... existing imports ...
import { dump } from "js-yaml";

// ... existing Thread interface ...

interface Thread {
  threadTs: string;
  channel: string;
  messages: Anthropic.MessageParam[];
  summary?: string;
}

// ... existing saveThread and loadThread functions ...

async function compactThread(thread: Thread): Promise<Thread> {
  const response = await anthropic.messages.create({
    model: "claude-opus-4-6",
    max_tokens: 8096,
    system: `You are a helpful Slack assistant. Your goal is to summarize the conversation so far.`, // Simplified system prompt for summarization
    messages: [
      ...thread.messages,
      {
        role: "user",
        content: `Please summarize the conversation so far in a concise way. The summary will be used to help me remember the context of the conversation. Do not include any information that is not relevant to the conversation.`, // Explicit instruction for summarization
      },
    ],
  });

  const summary = response.content.map((block) => block.text).join("\n");

  return {
    ...thread,
    messages: [
      { role: "assistant", content: summary }, // Replace old messages with summary
    ],
    summary,
  };
}

app.event("message", async ({ event }) => {
  // ... existing code up to the status indicator ...

  let thread: Thread = loadThread(threadTs) ?? {
    threadTs,
    channel,
    messages: [],
  };

  // Check if compaction is needed
  const totalTokens = await anthropic.countTokens({
    model: "claude-opus-4-6",
    messages: thread.messages,
  });

  if (totalTokens > 4000) { // Arbitrary threshold for compaction
    thread = await compactThread(thread);
  }

  const messages = await generateMessages({
    channel: event.channel,
    threadTs,
    system: "You are a helpful Slack assistant.",
    messages: [
      ...thread.messages,
      {
        role: "user",
        content: `User <@${event.user}> sent this message (timestamp: ${event.ts}) in Slack:\n\`\`\`\n${event.text}\n\`\`\`\n\nYou must respond using the \`send_slack_message\` tool.`,
      },
    ],
  });
  saveThread(threadTs, {
    ...thread,
    messages,
  });
});

这是目前为止的完整代码。

机器人现在会压缩对话历史记录,以避免超出LLM的上下文窗口。

技能

OpenClaw最强大的功能之一是它能够使用技能。技能是LLM可以调用的函数,以执行特定任务。例如,一个技能可以用来搜索网络,另一个技能可以用来生成图像。

我们将添加一个简单的技能,允许LLM搜索网络。我们将使用axios库来发出HTTP请求。

bash 复制代码
bun add axios

我们将创建一个skills目录,并在其中添加一个web_search.ts文件:

typescript 复制代码
// skills/web_search.ts
import axios from "axios";

export async function webSearch(query: string): Promise<string> {
  const response = await axios.get("https://api.duckduckgo.com/html", {
    params: {
      q: query,
    },
  });
  // Parse the HTML response to extract relevant information
  // This is a simplified example, a real implementation would use a more robust HTML parser
  return response.data.match(/<a class="result__a" href="(.*?)">/)?.[1] || "No results found.";
}

现在,我们将修改createTools函数以包含web_search技能:

typescript 复制代码
// ... existing imports ...
import { webSearch } from "./skills/web_search";

function createTools(channel: string, threadTs: string): ToolWithExecute[] {
  return [
    // ... existing send_slack_message tool ...
    {
      name: "web_search",
      description: "Search the web for a given query.",
      input_schema: {
        type: "object",
        properties: {
          query: {
            type: "string",
            description: "The search query.",
          },
        },
        required: ["query"],
      },
      execute: async (input: { query: string }) => {
        return await webSearch(input.query);
      },
    },
  ];
}

现在,LLM将能够使用web_search技能来搜索网络。例如,如果你问它"OpenClaw是什么?",它可能会使用web_search技能来查找相关信息。

主动行动

OpenClaw最强大的功能之一是它能够主动行动,而无需明确提示。例如,它可以监视GitHub仓库的更新,并在有新提交时通知你。

我们将添加一个简单的机制,允许LLM主动行动。我们将使用一个cron作业来定期触发LLM,并让它决定是否需要采取行动。

typescript 复制代码
// ... existing imports ...
import { CronJob } from "cron";

// ... existing app.event("message") handler ...

new CronJob(
  "0 * * * * *", // Run every minute
  async () => {
    console.log("Cron job triggered");
    // Load all threads
    const threadFiles = fs.readdirSync(threadsDir);
    for (const threadFile of threadFiles) {
      const threadTs = threadFile.replace(".json", "");
      let thread: Thread = loadThread(threadTs)!;

      // Ask the LLM if it needs to take any proactive action
      const messages = await generateMessages({
        channel: thread.channel,
        threadTs,
        system: "You are a helpful Slack assistant. You are running as a cron job. You can take proactive actions without explicit prompting. If you have nothing to say, respond with NO_REPLY.",
        messages: [
          ...thread.messages,
          {
            role: "user",
            content: "Do you need to take any proactive actions? If not, respond with NO_REPLY.",
          },
        ],
      });

      // If the LLM responded with NO_REPLY, do nothing
      if (messages.length === thread.messages.length + 1 && messages[messages.length - 1].content === "NO_REPLY") {
        continue;
      }

      saveThread(threadTs, {
        ...thread,
        messages,
      });
    }
  },
  null, // onComplete
  true, // start
  "America/Los_Angeles" // timeZone
);

这是目前为止的完整代码。

机器人现在会主动行动,而无需明确提示。例如,它可以监视GitHub仓库的更新,并在有新提交时通知你。

结论

在这篇文章中,我们从零开始构建了一个类似OpenClaw的智能体,只用了不到400行代码。我们涵盖了以下功能:

  • 响应Slack消息
  • 作为LLM回复
  • 跟踪对话
  • 记忆压缩
  • 使用技能
  • 主动行动

我希望这篇博客文章能帮助你更好地理解OpenClaw的内部工作原理,并将这些想法应用到你自己的智能体系统中。

你可以在这里找到完整的代码。

参考文献

相关推荐
miss1 小时前
AI Agent 前端开发:一个初级工程师的踩坑成长之路
前端
锦木烁光1 小时前
Flowable 实战:从架构解耦到多状态动态查询的高性能重构方案
前端·后端
子淼8122 小时前
HTML入门指南:构建网页的基石
前端·html
农夫山泉不太甜2 小时前
Electron离屏渲染技术详
前端
深念Y2 小时前
Chrome MCP Server 配置失败全记录:一场历时数小时的“fetch failed”排查之旅
前端·自动化测试·chrome·http·ai·agent·mcp
一个有故事的男同学2 小时前
从零打造专业级前端 SDK (四):错误监控与生产发布
前端
2601_948606182 小时前
从 jQuery → V/R → Lit:前端架构的 15 年轮回
前端·架构·jquery
wuhen_n2 小时前
Vite 核心原理:ESM 带来的开发时“瞬移”体验
前端·javascript·vue.js
nibabaoo2 小时前
前端开发攻略---vue3长列表性能优化终极指南:虚拟滚动、分页加载、时间分片等6种方案详解与代码实现
前端·javascript·vue.js·虚拟滚动·分页加载·长列表·时间分片