如何手写一个简单的 OpenClaw:从零实现 AI Agent 框架

通过 500 行代码实现一个简化版 OpenClaw,彻底理解 AI Agent 的核心原理

开篇

最近 OpenClaw 火得一塌糊涂,24 万 Star 超越 React 成为 GitHub 历史第一。作为一个 10 年前端老兵,我花了几天时间研究它的架构,写了一篇《OpenClaw 底层原理深度解析》。

但看懂架构是一回事,能不能自己写出来是另一回事。

于是我决定挑战自己:用 500 行代码手写一个简化版 OpenClaw

这个过程让我对 AI Agent 的理解从"知道怎么回事"变成了"知道怎么做"。这篇文章就是我的实现过程分享。

这篇文章能帮你:

  • 从零实现一个可运行的 AI Agent 框架
  • 理解 Agent Loop、Tool Calling、Memory 的核心原理
  • 掌握流式响应、错误处理等实战技巧
  • 获得一份可以直接跑起来的代码

前置要求:

  • 熟悉 Node.js 和 TypeScript
  • 了解 AI API 的基本调用方式
  • 最好先看过我之前的《OpenClaw 底层原理深度解析》

设计目标:做减法

OpenClaw 功能很多,我们不可能全部实现。核心目标是抓住本质

OpenClaw 完整功能 我们的简化版
支持 10+ 聊天平台 只支持命令行
复杂的 Memory 系统 简单的内存数组
插件式 Skills 内置几个工具
WebSocket 服务器 直接函数调用
沙箱安全执行 基础的命令过滤
多轮迭代控制 简单的最大轮数

但核心的 Agent Loop(智能体循环)我们会完整实现,这是 AI Agent 的灵魂。

架构设计:三层结构

┌─────────────────────────────────────────────────┐
│                   MiniClaw                       │
│            (我们的简化版 OpenClaw)                │
└─────────────────────────────────────────────────┘
                       ↓
┌─────────────────────────────────────────────────┐
│            Layer 1: Agent Core                   │
│     - Agent Loop (思考-执行循环)                  │
│     - Tool Executor (工具执行器)                  │
│     - Message Builder (消息构建器)                │
└─────────────────────────────────────────────────┘
                       ↓
┌─────────────────────────────────────────────────┐
│            Layer 2: Memory                       │
│     - Conversation History (对话历史)            │
│     - Tool Results (工具结果)                    │
└─────────────────────────────────────────────────┘
                       ↓
┌─────────────────────────────────────────────────┐
│            Layer 3: LLM Client                   │
│     - API 调用封装                               │
│     - 流式响应处理                               │
│     - 错误重试                                   │
└─────────────────────────────────────────────────┘

第一步:定义核心类型

先定义好 TypeScript 类型,让代码更清晰:

// types.ts

// 消息类型
interface Message {
  role: 'system' | 'user' | 'assistant' | 'tool';
  content: string;
  tool_call_id?: string;    // 工具调用的 ID
  tool_calls?: ToolCall[];  // 助手请求的工具调用
}

// 工具调用
interface ToolCall {
  id: string;
  type: 'function';
  function: {
    name: string;
    arguments: string;  // JSON 字符串
  };
}

// 工具定义
interface Tool {
  name: string;
  description: string;
  parameters: {
    type: 'object';
    properties: Record<string, {
      type: string;
      description: string;
    }>;
    required: string[];
  };
  execute: (args: Record<string, any>) => Promise<string>;
}

// Agent 配置
interface AgentConfig {
  model: string;
  apiKey: string;
  apiEndpoint?: string;
  systemPrompt: string;
  tools: Tool[];
  maxIterations: number;  // 最大循环次数,防止无限循环
}

第二步:实现 LLM 客户端

LLM 客户端负责调用 AI API,支持流式响应:

// llm-client.ts

import { Message, ToolCall } from './types';

interface LLMResponse {
  content: string;
  toolCalls: ToolCall[];
  finishReason: 'stop' | 'tool_calls' | 'length';
}

class LLMClient {
  private apiKey: string;
  private apiEndpoint: string;
  private model: string;

  constructor(config: {
    apiKey: string;
    apiEndpoint?: string;
    model: string;
  }) {
    this.apiKey = config.apiKey;
    this.apiEndpoint = config.apiEndpoint || 'https://api.anthropic.com/v1/messages';
    this.model = config.model;
  }

  async chat(
    messages: Message[],
    tools: any[],
    onChunk?: (chunk: string) => void
  ): Promise<LLMResponse> {
    // 转换消息格式(适配 Claude API)
    const formattedMessages = this.formatMessages(messages);

    const response = await fetch(this.apiEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': this.apiKey,
        'anthropic-version': '2023-06-01'
      },
      body: JSON.stringify({
        model: this.model,
        max_tokens: 4096,
        messages: formattedMessages,
        tools: this.formatTools(tools),
        stream: true  // 启用流式响应
      })
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`API 调用失败: ${error}`);
    }

    // 处理流式响应
    return this.handleStream(response, onChunk);
  }

  private formatMessages(messages: Message[]): any[] {
    // 将我们的消息格式转换为 Claude API 格式
    const result: any[] = [];

    for (const msg of messages) {
      if (msg.role === 'system') {
        // Claude 的 system 放在请求根级别,这里跳过
        continue;
      }

      if (msg.role === 'tool') {
        // 工具结果
        result.push({
          role: 'user',
          content: [{
            type: 'tool_result',
            tool_use_id: msg.tool_call_id,
            content: msg.content
          }]
        });
      } else if (msg.tool_calls && msg.tool_calls.length > 0) {
        // 助手的工具调用
        result.push({
          role: 'assistant',
          content: msg.tool_calls.map(tc => ({
            type: 'tool_use',
            id: tc.id,
            name: tc.function.name,
            input: JSON.parse(tc.function.arguments)
          }))
        });
      } else {
        result.push({
          role: msg.role,
          content: msg.content
        });
      }
    }

    return result;
  }

  private formatTools(tools: any[]): any[] {
    // 转换为 Claude 的工具格式
    return tools.map(tool => ({
      name: tool.name,
      description: tool.description,
      input_schema: tool.parameters
    }));
  }

  private async handleStream(
    response: Response,
    onChunk?: (chunk: string) => void
  ): Promise<LLMResponse> {
    const reader = response.body?.getReader();
    if (!reader) throw new Error('无法读取响应流');

    const decoder = new TextDecoder();
    let content = '';
    let toolCalls: ToolCall[] = [];
    let finishReason: 'stop' | 'tool_calls' | 'length' = 'stop';
    let currentToolUse: any = null;

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value);
      const lines = chunk.split('\n').filter(line => line.startsWith('data: '));

      for (const line of lines) {
        const data = line.slice(6);  // 去掉 'data: ' 前缀
        if (data === '[DONE]') continue;

        try {
          const event = JSON.parse(data);

          if (event.type === 'content_block_start') {
            if (event.content_block.type === 'tool_use') {
              currentToolUse = {
                id: event.content_block.id,
                name: event.content_block.name,
                arguments: ''
              };
            }
          } else if (event.type === 'content_block_delta') {
            if (event.delta.type === 'text_delta') {
              content += event.delta.text;
              onChunk?.(event.delta.text);
            } else if (event.delta.type === 'input_json_delta') {
              if (currentToolUse) {
                currentToolUse.arguments += event.delta.partial_json;
              }
            }
          } else if (event.type === 'content_block_stop') {
            if (currentToolUse) {
              toolCalls.push({
                id: currentToolUse.id,
                type: 'function',
                function: {
                  name: currentToolUse.name,
                  arguments: currentToolUse.arguments
                }
              });
              currentToolUse = null;
            }
          } else if (event.type === 'message_delta') {
            if (event.delta.stop_reason === 'tool_use') {
              finishReason = 'tool_calls';
            }
          }
        } catch (e) {
          // 忽略解析错误,继续处理
        }
      }
    }

    return { content, toolCalls, finishReason };
  }
}

export { LLMClient, LLMResponse };

这段代码的关键点:

  1. 流式响应处理:使用 ReadableStream 逐块读取,实现打字机效果
  2. 消息格式转换:将我们的标准格式转换为 Claude API 格式
  3. 工具调用解析:从流中正确提取工具调用信息

第三步:实现内置工具

我们实现几个基础但实用的工具:

// tools.ts

import { Tool } from './types';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';

const execAsync = promisify(exec);

// 安全命令白名单
const SAFE_COMMANDS = ['ls', 'cat', 'head', 'tail', 'grep', 'wc', 'date', 'pwd', 'echo'];

// 危险模式黑名单
const DANGEROUS_PATTERNS = [
  /rm\s+-rf/,
  />\s*\//,
  /;\s*rm/,
  /\|\s*sh/,
  /\$\(/,
  /`/
];

function isSafeCommand(cmd: string): boolean {
  // 检查危险模式
  for (const pattern of DANGEROUS_PATTERNS) {
    if (pattern.test(cmd)) {
      return false;
    }
  }

  // 检查命令是否在白名单
  const baseCommand = cmd.trim().split(/\s+/)[0];
  return SAFE_COMMANDS.includes(baseCommand);
}

// 工具1: 执行 Shell 命令
const shellTool: Tool = {
  name: 'execute_shell',
  description: '执行 shell 命令。只能执行安全的只读命令,如 ls、cat、grep 等。',
  parameters: {
    type: 'object',
    properties: {
      command: {
        type: 'string',
        description: '要执行的 shell 命令'
      }
    },
    required: ['command']
  },
  async execute(args) {
    const { command } = args;

    if (!isSafeCommand(command)) {
      return `错误:该命令不在安全白名单中。只允许执行:${SAFE_COMMANDS.join(', ')}`;
    }

    try {
      const { stdout, stderr } = await execAsync(command, {
        timeout: 30000,  // 30 秒超时
        maxBuffer: 1024 * 1024  // 1MB 缓冲区
      });

      return stdout || stderr || '命令执行成功(无输出)';
    } catch (error: any) {
      return `命令执行失败:${error.message}`;
    }
  }
};

// 工具2: 读取文件
const readFileTool: Tool = {
  name: 'read_file',
  description: '读取指定文件的内容',
  parameters: {
    type: 'object',
    properties: {
      path: {
        type: 'string',
        description: '文件路径'
      },
      maxLines: {
        type: 'number',
        description: '最多读取的行数,默认 100'
      }
    },
    required: ['path']
  },
  async execute(args) {
    const { path: filePath, maxLines = 100 } = args;

    try {
      const content = await fs.readFile(filePath, 'utf-8');
      const lines = content.split('\n');

      if (lines.length > maxLines) {
        return lines.slice(0, maxLines).join('\n') +
          `\n\n... 文件共 ${lines.length} 行,只显示前 ${maxLines}`;
      }

      return content;
    } catch (error: any) {
      return `读取文件失败:${error.message}`;
    }
  }
};

// 工具3: 写入文件
const writeFileTool: Tool = {
  name: 'write_file',
  description: '将内容写入指定文件',
  parameters: {
    type: 'object',
    properties: {
      path: {
        type: 'string',
        description: '文件路径'
      },
      content: {
        type: 'string',
        description: '要写入的内容'
      }
    },
    required: ['path', 'content']
  },
  async execute(args) {
    const { path: filePath, content } = args;

    // 安全检查:不允许写入系统目录
    const absolutePath = path.resolve(filePath);
    if (absolutePath.startsWith('/etc') ||
        absolutePath.startsWith('/usr') ||
        absolutePath.startsWith('/bin')) {
      return '错误:不允许写入系统目录';
    }

    try {
      // 确保目录存在
      await fs.mkdir(path.dirname(absolutePath), { recursive: true });
      await fs.writeFile(absolutePath, content, 'utf-8');
      return `文件已写入:${absolutePath}`;
    } catch (error: any) {
      return `写入文件失败:${error.message}`;
    }
  }
};

// 工具4: 网络请求
const fetchTool: Tool = {
  name: 'fetch_url',
  description: '获取指定 URL 的内容',
  parameters: {
    type: 'object',
    properties: {
      url: {
        type: 'string',
        description: '要请求的 URL'
      },
      method: {
        type: 'string',
        description: 'HTTP 方法,默认 GET'
      }
    },
    required: ['url']
  },
  async execute(args) {
    const { url, method = 'GET' } = args;

    try {
      const response = await fetch(url, { method });
      const contentType = response.headers.get('content-type') || '';

      if (contentType.includes('application/json')) {
        const json = await response.json();
        return JSON.stringify(json, null, 2);
      }

      const text = await response.text();

      // 限制返回长度
      if (text.length > 5000) {
        return text.substring(0, 5000) + '\n\n... 内容过长,已截断';
      }

      return text;
    } catch (error: any) {
      return `请求失败:${error.message}`;
    }
  }
};

// 工具5: 计算器
const calculatorTool: Tool = {
  name: 'calculator',
  description: '执行数学计算',
  parameters: {
    type: 'object',
    properties: {
      expression: {
        type: 'string',
        description: '数学表达式,如 "2 + 2 * 3"'
      }
    },
    required: ['expression']
  },
  async execute(args) {
    const { expression } = args;

    // 安全检查:只允许数字和运算符
    if (!/^[\d\s+\-*/().]+$/.test(expression)) {
      return '错误:表达式包含不允许的字符';
    }

    try {
      // 使用 Function 构造器计算(比 eval 稍安全)
      const result = new Function(`return ${expression}`)();
      return `计算结果:${expression} = ${result}`;
    } catch (error: any) {
      return `计算失败:${error.message}`;
    }
  }
};

// 导出所有工具
export const builtinTools: Tool[] = [
  shellTool,
  readFileTool,
  writeFileTool,
  fetchTool,
  calculatorTool
];

安全设计的几个要点:

  1. 命令白名单:只允许执行预定义的安全命令
  2. 危险模式检测:阻止 rm -rf、命令注入等危险操作
  3. 路径限制:禁止写入系统目录
  4. 超时控制:防止命令执行过长
  5. 输出限制:防止返回过多内容消耗 token

第四步:实现 Agent Loop(核心!)

这是整个框架的灵魂——Agent 循环:

// agent.ts

import { LLMClient } from './llm-client';
import { Tool, Message, AgentConfig, ToolCall } from './types';
import { builtinTools } from './tools';

class MiniClawAgent {
  private llm: LLMClient;
  private tools: Map<string, Tool>;
  private config: AgentConfig;
  private memory: Message[];

  constructor(config: AgentConfig) {
    this.config = config;
    this.llm = new LLMClient({
      apiKey: config.apiKey,
      apiEndpoint: config.apiEndpoint,
      model: config.model
    });

    // 注册工具
    this.tools = new Map();
    for (const tool of [...builtinTools, ...config.tools]) {
      this.tools.set(tool.name, tool);
    }

    // 初始化记忆(对话历史)
    this.memory = [
      { role: 'system', content: this.buildSystemPrompt() }
    ];
  }

  // 构建系统提示词
  private buildSystemPrompt(): string {
    const toolDescriptions = Array.from(this.tools.values())
      .map(t => `- ${t.name}: ${t.description}`)
      .join('\n');

    return `${this.config.systemPrompt}

你可以使用以下工具来完成任务:
${toolDescriptions}

重要规则:
1. 当需要执行操作时,使用工具而不是假装执行
2. 每次只调用必要的工具,避免过度调用
3. 在回复用户前,确保已完成所有必要的操作
4. 如果工具执行失败,尝试其他方法或告知用户

当前时间:${new Date().toISOString()}
`;
  }

  // 核心:Agent 循环
  async run(
    userMessage: string,
    onChunk?: (chunk: string) => void
  ): Promise<string> {
    // 1. 添加用户消息到记忆
    this.memory.push({ role: 'user', content: userMessage });

    let iteration = 0;
    let finalResponse = '';

    // Agent 循环
    while (iteration < this.config.maxIterations) {
      iteration++;
      console.log(`\n--- 第 ${iteration} 轮迭代 ---`);

      // 2. 调用 LLM
      const response = await this.llm.chat(
        this.memory,
        this.getToolDefinitions(),
        onChunk
      );

      // 3. 根据响应类型决定下一步
      if (response.finishReason === 'stop') {
        // 没有工具调用,任务完成
        finalResponse = response.content;
        this.memory.push({ role: 'assistant', content: response.content });
        break;
      }

      if (response.finishReason === 'tool_calls' && response.toolCalls.length > 0) {
        // 有工具调用,执行工具
        console.log(`发现 ${response.toolCalls.length} 个工具调用`);

        // 记录助手的工具调用请求
        this.memory.push({
          role: 'assistant',
          content: response.content,
          tool_calls: response.toolCalls
        });

        // 4. 执行所有工具调用
        for (const toolCall of response.toolCalls) {
          console.log(`执行工具: ${toolCall.function.name}`);

          const result = await this.executeTool(toolCall);

          // 将工具结果添加到记忆
          this.memory.push({
            role: 'tool',
            content: result,
            tool_call_id: toolCall.id
          });
        }

        // 继续循环,让 LLM 处理工具结果
        continue;
      }

      // 其他情况(如达到 token 限制),退出循环
      finalResponse = response.content || '任务处理异常';
      break;
    }

    if (iteration >= this.config.maxIterations) {
      finalResponse += '\n\n(已达到最大迭代次数,任务可能未完成)';
    }

    return finalResponse;
  }

  // 执行单个工具
  private async executeTool(toolCall: ToolCall): Promise<string> {
    const tool = this.tools.get(toolCall.function.name);

    if (!tool) {
      return `错误:未找到工具 "${toolCall.function.name}"`;
    }

    try {
      const args = JSON.parse(toolCall.function.arguments);
      console.log(`工具参数:`, args);

      const result = await tool.execute(args);
      console.log(`工具结果:`, result.substring(0, 200) + (result.length > 200 ? '...' : ''));

      return result;
    } catch (error: any) {
      console.error(`工具执行错误:`, error);
      return `工具执行失败:${error.message}`;
    }
  }

  // 获取工具定义(用于 LLM 调用)
  private getToolDefinitions(): any[] {
    return Array.from(this.tools.values()).map(tool => ({
      name: tool.name,
      description: tool.description,
      parameters: tool.parameters
    }));
  }

  // 获取对话历史
  getHistory(): Message[] {
    return [...this.memory];
  }

  // 清空对话历史
  clearHistory(): void {
    this.memory = [
      { role: 'system', content: this.buildSystemPrompt() }
    ];
  }
}

export { MiniClawAgent };

Agent Loop 的核心逻辑图解:

用户输入
    ↓
┌──────────────────────────────────────┐
│            Agent Loop                 │
│                                       │
│  ┌─────────────────────────────────┐ │
│  │ 1. 添加用户消息到 Memory        │ │
│  └─────────────────────────────────┘ │
│                 ↓                     │
│  ┌─────────────────────────────────┐ │
│  │ 2. 调用 LLM(带 Memory + Tools)│ │
│  └─────────────────────────────────┘ │
│                 ↓                     │
│  ┌─────────────────────────────────┐ │
│  │ 3. 检查响应类型                 │ │
│  │    ├─ stop → 返回最终回复       │ │
│  │    └─ tool_calls → 执行工具     │ │
│  └─────────────────────────────────┘ │
│                 ↓ (如果是 tool_calls) │
│  ┌─────────────────────────────────┐ │
│  │ 4. 执行工具,结果加入 Memory    │ │
│  └─────────────────────────────────┘ │
│                 ↓                     │
│           回到步骤 2                  │
└──────────────────────────────────────┘
    ↓
最终回复

这就是 AI Agent 的本质:不断循环 “思考 → 行动 → 观察” 直到完成任务

第五步:实现命令行界面

最后,我们需要一个交互界面:

// cli.ts

import * as readline from 'readline';
import { MiniClawAgent } from './agent';

async function main() {
  // 从环境变量获取 API Key
  const apiKey = process.env.ANTHROPIC_API_KEY;
  if (!apiKey) {
    console.error('请设置 ANTHROPIC_API_KEY 环境变量');
    process.exit(1);
  }

  // 创建 Agent 实例
  const agent = new MiniClawAgent({
    model: 'claude-sonnet-4-20250514',
    apiKey,
    systemPrompt: `你是 MiniClaw,一个简化版的 AI Agent。
你可以帮助用户完成各种任务,包括:
- 执行 shell 命令查看系统信息
- 读取和写入文件
- 进行网络请求
- 数学计算

请简洁、准确地回答问题,必要时使用工具完成任务。`,
    tools: [],  // 使用内置工具
    maxIterations: 10
  });

  // 创建命令行界面
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  console.log('========================================');
  console.log('🤖 MiniClaw - 简化版 OpenClaw');
  console.log('========================================');
  console.log('输入消息与 Agent 对话,输入 "exit" 退出');
  console.log('输入 "clear" 清空对话历史');
  console.log('----------------------------------------\n');

  const prompt = () => {
    rl.question('你: ', async (input) => {
      const trimmed = input.trim();

      if (trimmed === 'exit') {
        console.log('再见!');
        rl.close();
        return;
      }

      if (trimmed === 'clear') {
        agent.clearHistory();
        console.log('对话历史已清空\n');
        prompt();
        return;
      }

      if (!trimmed) {
        prompt();
        return;
      }

      try {
        process.stdout.write('MiniClaw: ');

        const response = await agent.run(trimmed, (chunk) => {
          // 流式输出
          process.stdout.write(chunk);
        });

        // 如果没有流式输出,直接打印完整响应
        if (!response.startsWith('MiniClaw: ')) {
          console.log();  // 换行
        }

        console.log('\n');
      } catch (error: any) {
        console.error(`\n错误: ${error.message}\n`);
      }

      prompt();
    });
  };

  prompt();
}

main();

运行效果演示

编译并运行:

# 安装依赖
npm init -y
npm install typescript @types/node
npx tsc --init

# 编译
npx tsc

# 运行(需要设置 API Key)
export ANTHROPIC_API_KEY="your-api-key"
node dist/cli.js

实际对话演示:

========================================
🤖 MiniClaw - 简化版 OpenClaw
========================================
输入消息与 Agent 对话,输入 "exit" 退出
输入 "clear" 清空对话历史
----------------------------------------

你: 帮我看看当前目录有什么文件

--- 第 1 轮迭代 ---
发现 1 个工具调用
执行工具: execute_shell
工具参数: { command: 'ls -la' }
工具结果: total 32
drwxr-xr-x  8 user staff  256 Mar 13 10:00 .
drwxr-xr-x  5 user staff  160 Mar 13 09:00 ..
-rw-r--r--  1 user staff 1234 Mar 13 10:00 agent.ts
-rw-r--r--  1 user staff  567 Mar 13 10:00 cli.ts
...

--- 第 2 轮迭代 ---
MiniClaw: 当前目录下有以下文件:

1. `agent.ts` - Agent 核心代码
2. `cli.ts` - 命令行界面
3. `llm-client.ts` - LLM 客户端
4. `tools.ts` - 工具定义
5. `types.ts` - 类型定义
6. `package.json` - 项目配置

你: 123 * 456 + 789 等于多少?

--- 第 1 轮迭代 ---
发现 1 个工具调用
执行工具: calculator
工具参数: { expression: '123 * 456 + 789' }
工具结果: 计算结果:123 * 456 + 789 = 56877

--- 第 2 轮迭代 ---
MiniClaw: 123 * 456 + 789 = 56877

你: 写一个 hello.txt 文件,内容是 "Hello, MiniClaw!"

--- 第 1 轮迭代 ---
发现 1 个工具调用
执行工具: write_file
工具参数: { path: 'hello.txt', content: 'Hello, MiniClaw!' }
工具结果: 文件已写入:/Users/xxx/projects/miniclaw/hello.txt

--- 第 2 轮迭代 ---
MiniClaw: 已创建 hello.txt 文件,内容为 "Hello, MiniClaw!"

在英博云平台部署

如果你想把这个 MiniClaw 部署到服务器,在英博云平台可以这样做:

Docker 部署配置

# Dockerfile
FROM node:20-alpine

WORKDIR /app

# 复制项目文件
COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

# 运行
CMD ["node", "dist/cli.js"]

部署步骤

# 1. 构建镜像
docker build -t miniclaw .

# 2. 在英博云平台创建容器实例
# 配置环境变量 ANTHROPIC_API_KEY

# 3. 运行
docker run -it \
  -e ANTHROPIC_API_KEY="your-key" \
  miniclaw

Web API 版本

如果想通过 HTTP 调用,可以加一个简单的 Express 服务:

// server.ts
import express from 'express';
import { MiniClawAgent } from './agent';

const app = express();
app.use(express.json());

// 存储会话
const sessions = new Map<string, MiniClawAgent>();

function getOrCreateAgent(sessionId: string): MiniClawAgent {
  if (!sessions.has(sessionId)) {
    sessions.set(sessionId, new MiniClawAgent({
      model: 'claude-sonnet-4-20250514',
      apiKey: process.env.ANTHROPIC_API_KEY!,
      systemPrompt: '你是 MiniClaw,一个有用的 AI 助手。',
      tools: [],
      maxIterations: 10
    }));
  }
  return sessions.get(sessionId)!;
}

app.post('/chat', async (req, res) => {
  const { sessionId, message } = req.body;

  if (!sessionId || !message) {
    return res.status(400).json({ error: '缺少 sessionId 或 message' });
  }

  const agent = getOrCreateAgent(sessionId);

  try {
    const response = await agent.run(message);
    res.json({ response });
  } catch (error: any) {
    res.status(500).json({ error: error.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`MiniClaw 服务运行在 http://localhost:${PORT}`);
});

踩坑分享

问题 1:流式响应解析错乱

现象: 工具调用的 JSON 参数解析失败

原因: Claude 的流式响应中,工具参数是逐字符发送的,需要累积拼接

解决:

// 错误做法
if (event.delta.type === 'input_json_delta') {
  const args = JSON.parse(event.delta.partial_json);  // 这会失败!
}

// 正确做法
let argumentsBuffer = '';
if (event.delta.type === 'input_json_delta') {
  argumentsBuffer += event.delta.partial_json;  // 累积
}
// 在 content_block_stop 时才解析
if (event.type === 'content_block_stop') {
  const args = JSON.parse(argumentsBuffer);  // 完整了再解析
}

问题 2:工具调用后 LLM 不响应

现象: 执行工具后,LLM 没有生成回复

原因: 消息格式不对,Claude 需要特定的 tool_result 格式

解决:

// 错误做法
messages.push({
  role: 'assistant',
  content: `工具结果:${result}`
});

// 正确做法
messages.push({
  role: 'user',
  content: [{
    type: 'tool_result',
    tool_use_id: toolCallId,
    content: result
  }]
});

问题 3:无限循环

现象: Agent 一直调用工具,停不下来

原因:

  1. 系统提示词没有明确告诉 Agent 何时停止
  2. 没有设置最大迭代次数

解决:

// 1. 在系统提示词中明确
const systemPrompt = `
...
当任务完成时,直接回复用户,不要继续调用工具。
如果无法完成任务,说明原因并停止。
`;

// 2. 设置最大迭代次数
const MAX_ITERATIONS = 10;
if (iteration >= MAX_ITERATIONS) {
  return '已达到最大迭代次数,任务可能未完成';
}

问题 4:Token 消耗过快

现象: 几轮对话就消耗大量 token

原因: 每次都发送完整历史,包括冗长的工具结果

解决:

// 压缩工具结果
function compressToolResult(result: string, maxLength = 500): string {
  if (result.length <= maxLength) return result;
  return result.substring(0, maxLength) + '\n... (结果已截断)';
}

// 定期压缩历史
function compressHistory(messages: Message[]): Message[] {
  if (messages.length < 20) return messages;

  // 保留 system 和最近 10 条
  const system = messages[0];
  const recent = messages.slice(-10);

  return [system, { role: 'system', content: '(之前的对话已省略)' }, ...recent];
}

进阶:添加更多能力

基础框架搭好后,可以继续扩展:

1. 添加向量记忆

// 使用 SQLite + 向量扩展
import Database from 'better-sqlite3';

class VectorMemory {
  private db: Database.Database;

  async store(text: string, embedding: number[]) {
    // 存储文本和向量
  }

  async search(query: string, limit = 5): Promise<string[]> {
    // 向量相似度搜索
  }
}

2. 添加 Skill 系统

// 动态加载 Markdown 定义的 Skill
async function loadSkills(dir: string): Promise<Tool[]> {
  const files = await fs.readdir(dir);
  const skills: Tool[] = [];

  for (const file of files) {
    if (file.endsWith('.md')) {
      const content = await fs.readFile(path.join(dir, file), 'utf-8');
      const skill = parseSkillMarkdown(content);
      skills.push(skill);
    }
  }

  return skills;
}

3. 添加 WebSocket 服务

// 支持外部客户端连接
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 18789 });

wss.on('connection', (ws) => {
  const agent = new MiniClawAgent(config);

  ws.on('message', async (data) => {
    const { type, content } = JSON.parse(data.toString());

    if (type === 'message') {
      const response = await agent.run(content, (chunk) => {
        ws.send(JSON.stringify({ type: 'chunk', content: chunk }));
      });
      ws.send(JSON.stringify({ type: 'done', content: response }));
    }
  });
});

总结

通过这 500 行代码,我们实现了一个简化但完整的 AI Agent 框架。核心要点:

技术收获

  1. Agent Loop 是灵魂:思考 → 行动 → 观察的循环
  2. 工具是手脚:让 AI 能"做事"而不只是"说话"
  3. Memory 是记忆:保持对话上下文和状态
  4. 流式响应是体验:让用户感知到 AI 在"思考"

设计启示

  1. 做减法:复杂系统的核心往往很简单
  2. 分层清晰:LLM Client、Agent Core、Tools 各司其职
  3. 安全第一:工具执行必须有防护
  4. 渐进增强:先跑起来,再慢慢完善

后续计划

  1. 添加向量记忆,实现长期记忆
  2. 实现多 Agent 协作
  3. 英博云平台部署完整服务
  4. 参考 OpenClaw 的 Skill 系统,实现插件化

参考资源


手写一遍比看十遍文档管用。如果你也想深入理解 AI Agent,建议跟着这篇文章自己实现一遍。代码不复杂,但每一行都是理解的过程。

有问题欢迎交流!

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐