用OpenClaw的架构思路,我给公司搭了一套内部AI Agent系统,替代了3个外包岗位
背景:为什么不直接用OpenClaw
先说结论:OpenClaw本身不适合直接丢进企业生产环境。
原因有三:应用层沙箱扛不住安全审计(CVE-2026-25253已经说明问题)、单体架构没法做高可用、记忆系统的本地文件方案不支持多实例共享。
但它的架构思路------消息驱动、三层记忆、工具调用链------确实是目前个人AI Agent框架里设计得最清晰的。我把这套思路抽出来,用公司现有的技术栈重新实现了一版,跑在内部的钉钉群里,接管了三个外包同事之前干的活:客户工单自动分类、日报周报自动汇总、竞品价格监控。
这篇文章把整个过程拆开讲,包括架构设计、核心模块实现、踩过的坑和最终的性能数据。
架构设计
整体思路
直接上架构图:
scss
┌──────────────────────────────────────────────────────────┐
│ Channel Adapter │
│ (钉钉Bot / 企微Bot / HTTP API) │
└─────────────────────────┬────────────────────────────────┘
│ 消息事件
▼
┌──────────────────────────────────────────────────────────┐
│ Message Router │
│ (会话管理 / 限流 / 消息队列 / 状态机) │
│ ┌─────────────┐ │
│ │ BullMQ队列 │ ← OpenClaw用进程内事件循环 │
│ └─────────────┘ 我们换成了持久化消息队列 │
└─────────────────────────┬────────────────────────────────┘
│ 任务分发
▼
┌──────────────────────────────────────────────────────────┐
│ Agent Runtime Pool │
│ (多Worker进程 / 上下文组装 / 模型调用) │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Worker 1 │ │ Worker 2 │ │ Worker 3 │ ← 可水平扩展 │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────┬────────────────────────────────┘
│ 工具调用 / 记忆读写
▼
┌──────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 记忆系统 │ │ 工具沙箱 │ │ Skill注册│ │ 监控告警 │ │
│ │ (PG+pgvec)│ │ (Docker) │ │ 中心 │ │(Prometheus│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────┘
跟OpenClaw原版架构的关键区别:
| 模块 | OpenClaw | 我们的方案 | 改造原因 |
|---|---|---|---|
| 消息队列 | 进程内EventEmitter | BullMQ + Redis | 需要持久化,防丢消息 |
| Agent执行 | 单进程单线程 | Worker进程池 | 需要并发,一个Worker挂了不影响其他 |
| 记忆存储 | 本地Markdown文件 | PostgreSQL + pgvector | 多实例共享,支持事务 |
| 工具沙箱 | 应用层路径白名单 | Docker容器隔离 | 安全审计要求 |
| 模型调用 | 直连API | 内部网关统一代理 | 统一计费、限流、审计日志 |
不是说OpenClaw的方案不好------对个人用户来说那套方案恰到好处。但企业场景的需求不一样,照搬必翻车。
技术选型
运行时:Node.js 20 LTS(跟OpenClaw保持一致,方便参考源码)
消息队列:BullMQ 5.x + Redis 7.x
数据库:PostgreSQL 16 + pgvector扩展
容器:Docker(工具沙箱)
模型:Claude Sonnet 4.6(主力) / GPT-4o(备用)
监控:Prometheus + Grafana
部署:公司内网K8s集群
选Node.js不是因为它最适合,而是因为OpenClaw的源码可以直接参考。如果从零开始,Python + FastAPI可能是更主流的选择。
核心模块实现
1. Channel Adapter:钉钉机器人接入
钉钉的Stream模式比传统Webhook好用得多------不需要公网IP,不需要配回调地址,直接WebSocket长连接。
php
// src/channels/dingtalk.ts
import { DWClient, EventAck, TOPIC_ROBOT } from 'dingtalk-stream';
class DingTalkChannel implements Channel {
private client: DWClient;
async connect(config: DingTalkConfig): Promise<void> {
this.client = new DWClient({
clientId: config.appKey,
clientSecret: config.appSecret,
});
this.client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
const data = JSON.parse(res.data);
const message: IncomingMessage = {
id: data.msgId,
conversationId: data.conversationId,
senderId: data.senderStaffId,
content: data.text.content.trim(),
platform: 'dingtalk',
timestamp: Date.now(),
// 钉钉特有:区分单聊和群聊
isGroup: data.conversationType === '2',
groupId: data.conversationId,
};
await this.messageHandler(message);
return EventAck.SUCCESS;
});
await this.client.start();
}
async sendMessage(conversationId: string, content: string): Promise<void> {
// 钉钉的回复需要用webhook URL
await this.client.sendMessage({
conversationId,
content: JSON.stringify({
msgtype: 'markdown',
markdown: { title: 'AI助手', text: content }
})
});
}
}
这里有个细节:钉钉群聊里@机器人的消息,content前面会带一个@xxx的前缀,需要strip掉,不然模型会困惑。
typescript
// 清理@前缀
function cleanMention(content: string): string {
return content.replace(/^@\S+\s*/, '').trim();
}
2. Message Router:基于BullMQ的任务调度
OpenClaw用进程内EventEmitter做消息调度,简单但脆弱------进程崩了消息就丢了。我们换成了BullMQ,关键改动是加了优先级队列 和死信处理。
typescript
// src/router/message-router.ts
import { Queue, Worker } from 'bullmq';
class MessageRouter {
private queue: Queue;
private connection = { host: 'redis-host', port: 6379 };
async init() {
this.queue = new Queue('agent-tasks', {
connection: this.connection,
defaultJobOptions: {
attempts: 3, // 最多重试3次
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 86400 }, // 完成的任务保留24小时
removeOnFail: { age: 604800 }, // 失败的保留7天
}
});
// 启动Worker池
for (let i = 0; i < 3; i++) {
this.createWorker(i);
}
}
async dispatch(message: IncomingMessage): Promise<void> {
// 根据消息类型设置优先级
const priority = this.calculatePriority(message);
await this.queue.add('process-message', message, { priority });
}
private calculatePriority(msg: IncomingMessage): number {
// 优先级:1最高,数字越大优先级越低
if (msg.content.startsWith('/urgent')) return 1; // 紧急指令
if (!msg.isGroup) return 2; // 单聊优先
return 5; // 群聊消息
}
private createWorker(id: number) {
const worker = new Worker('agent-tasks', async (job) => {
const message = job.data as IncomingMessage;
try {
const runtime = new AgentRuntime(this.config);
const response = await runtime.process(message);
await this.channelManager.reply(message, response);
} catch (err) {
// 区分可重试和不可重试的错误
if (err instanceof ModelAPIError && err.statusCode === 429) {
throw err; // 429限流,BullMQ会自动重试
}
if (err instanceof ModelAPIError && err.statusCode === 403) {
// API Key无效,不重试,直接回复错误
await this.channelManager.reply(message, '模型服务暂时不可用,请联系管理员');
return; // 不throw,标记任务完成
}
throw err;
}
}, {
connection: this.connection,
concurrency: 2, // 每个Worker的并发数
limiter: { max: 10, duration: 60000 }, // 每分钟最多处理10个任务
});
worker.on('failed', (job, err) => {
console.error(`Worker ${id} 任务失败: ${job?.id}`, err.message);
// 发到监控系统
metrics.increment('agent.task.failed');
});
}
}
为什么用3个Worker、每个并发2?因为我们的主力模型Claude Sonnet的API并发限制是Tier 2的5 req/min,3×2=6刚好卡在限制边缘。实际跑的时候靠BullMQ的limiter兜底。
3. 记忆系统:从Markdown到PostgreSQL + pgvector
这是改动最大的模块。OpenClaw的三层记忆模型设计得很好,我们保留了这个分层思路,但把存储换成了PostgreSQL。
sql
-- 建表
CREATE EXTENSION IF NOT EXISTS vector;
-- 短期记忆(会话消息)
CREATE TABLE short_term_memory (
id SERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL, -- 'user' | 'assistant' | 'tool'
content TEXT NOT NULL,
tool_calls JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- 自动清理:只保留最近的消息
INDEX idx_stm_conv_time (conversation_id, created_at DESC)
);
-- 中期记忆(会话摘要)
CREATE TABLE mid_term_memory (
id SERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
summary TEXT NOT NULL,
key_facts JSONB, -- 结构化的关键信息
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- 过期时间,到期自动清理
INDEX idx_mtm_user (user_id, created_at DESC)
);
-- 长期记忆(带向量索引)
CREATE TABLE long_term_memory (
id SERIAL PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
category VARCHAR(32), -- 'preference' | 'fact' | 'config'
embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small维度
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 向量相似度检索索引(IVFFlat,适合10万级数据)
CREATE INDEX idx_ltm_embedding ON long_term_memory
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
检索的时候,语义查询走pgvector:
typescript
// src/memory/long-term-store.ts
class LongTermStore {
async retrieve(query: string, userId: string, limit = 5): Promise<MemoryEntry[]> {
// 1. 生成查询向量
const embedding = await this.embeddings.embed(query);
// 2. pgvector相似度检索 + 用户过滤
const results = await this.db.query(`
SELECT id, content, category,
1 - (embedding <=> $1::vector) AS similarity
FROM long_term_memory
WHERE user_id = $2
ORDER BY embedding <=> $1::vector
LIMIT $3
`, [JSON.stringify(embedding), userId, limit * 2]);
// 3. 过滤低相似度结果(阈值0.7)
const filtered = results.rows.filter(r => r.similarity > 0.7);
// 4. 如果候选太多,用模型做reranking
if (filtered.length > limit) {
return this.rerank(query, filtered, limit);
}
return filtered.slice(0, limit);
}
}
为什么不用专门的向量数据库(Pinecone、Milvus)?因为公司内部数据不允许出内网,自建Milvus运维成本太高,pgvector刚好------数据库本来就有PostgreSQL,加个扩展就行。10万级别以内的数据量,IVFFlat索引的检索速度完全够用(P99 < 50ms)。
4. 工具沙箱:Docker容器隔离
这是跟OpenClaw差异最大的地方。OpenClaw的应用层沙箱(路径白名单+命令黑名单)过不了安全审计,我们用Docker做了真正的隔离。
typescript
// src/sandbox/docker-sandbox.ts
import Docker from 'dockerode';
class DockerSandbox {
private docker = new Docker();
async execute(
command: string,
args: string[],
options: SandboxOptions
): Promise<SandboxResult> {
const container = await this.docker.createContainer({
Image: 'openclaw-sandbox:latest', // 预构建的精简镜像
Cmd: [command, ...args],
HostConfig: {
Memory: 256 * 1024 * 1024, // 内存限制256MB
CpuPeriod: 100000,
CpuQuota: 50000, // CPU限制50%
NetworkMode: options.allowNetwork ? 'bridge' : 'none',
ReadonlyRootfs: true, // 只读根文件系统
SecurityOpt: ['no-new-privileges'],
Binds: options.mounts || [], // 只挂载必要的目录
},
StopTimeout: options.timeout || 30,
});
await container.start();
// 等待执行完成,带超时
const result = await Promise.race([
this.waitForCompletion(container),
this.timeoutReject(options.timeout || 30000)
]);
// 执行完毕立即销毁容器
try { await container.remove({ force: true }); } catch {}
return result;
}
private async waitForCompletion(container: Docker.Container): Promise<SandboxResult> {
const stream = await container.logs({ follow: true, stdout: true, stderr: true });
let stdout = '', stderr = '';
return new Promise((resolve) => {
stream.on('data', (chunk: Buffer) => {
// Docker日志流的前8字节是header
const payload = chunk.slice(8).toString();
const streamType = chunk[0]; // 1=stdout, 2=stderr
if (streamType === 1) stdout += payload;
else stderr += payload;
});
stream.on('end', () => {
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 });
});
});
}
}
沙箱镜像是基于Alpine Linux精简的,只包含Python 3.11、Node.js 20和常用的CLI工具。镜像大小控制在150MB以内。
sql
# Dockerfile.sandbox
FROM alpine:3.19
RUN apk add --no-cache nodejs npm python3 py3-pip curl jq
RUN adduser -D -s /bin/sh sandbox
USER sandbox
WORKDIR /workspace
每次工具调用都创建一个新容器、执行完立即销毁。听起来开销大,但实测容器创建+启动在200ms左右(Docker的轻量级容器很快),对于Agent任务的总延迟(通常3-10秒)来说可以接受。
实际业务场景
跑了两周,三个场景的效果:
场景1:客户工单自动分类
之前一个外包同事每天花4小时人工看工单、打标签、分派。现在Agent从钉钉群里自动读取工单消息,调用模型分类后写入工单系统。
erlang
处理量:日均120-150条工单
准确率:93.7%(抽样500条人工复核)
处理延迟:平均2.3秒/条
人工干预率:6.3%(模型不确定的会@人工确认)
场景2:日报周报自动汇总
从钉钉群的日报消息中提取关键信息,每周五自动生成团队周报。用到了中期记忆------Agent需要"记住"本周每天的日报内容,周五时汇总。
erlang
覆盖范围:3个项目组,27人
生成时间:周报约45秒(需要检索5天的记忆)
人工修改率:约15%的周报需要微调措辞
场景3:竞品价格监控
每4小时自动抓取竞品在各电商平台的价格,变动超过阈值时在钉钉群里@运营。这个场景用到了Docker沙箱------抓取脚本在隔离环境里跑。
objectivec
监控商品数:86个SKU,3个平台
检查频率:每4小时
价格变动捕获率:100%(跟人工每日检查对比两周,无遗漏)
月度成本:API调用约¥400 + 服务器资源约¥200
成本对比
| 项目 | 之前(3个外包) | 现在(Agent系统) |
|---|---|---|
| 月度人力成本 | ¥36,000 | ¥0 |
| 服务器/API | ¥0 | ¥600 |
| 开发投入(一次性) | - | 约3人周 |
| 月度维护 | - | 约2小时/月 |
| 月度总成本 | ¥36,000 | ¥600 |
三个外包岗的月成本是3.6万,Agent系统的月运行成本600块。开发投入约3人周,按内部人力成本算不到2万。不到一个月就回本了。
不过说句公道话,Agent干的是这三个岗位中重复性最高的那部分工作。真正需要判断力和沟通能力的事情,还是得人来。
踩坑记录
几个值得记录的坑:
坑1:pgvector的IVFFlat索引需要先有数据才能建
空表上建IVFFlat索引会报错,需要先插入一批数据(至少跟lists参数数量相当),再建索引。我们的做法是先用一批测试数据灌进去,建好索引,再清掉测试数据。
坑2:BullMQ的Worker并发和模型API限流容易打架
Worker并发设太高,API返回429;设太低,消息堆积。最后用了动态调整:监控429的频率,超过阈值时自动降低Worker并发。
ini
// 简化的动态限流
let currentConcurrency = 2;
const RATE_LIMIT_WINDOW = 60000;
let rateLimitHits = 0;
worker.on('failed', (job, err) => {
if (err.message.includes('429')) {
rateLimitHits++;
if (rateLimitHits > 3 && currentConcurrency > 1) {
currentConcurrency--;
worker.concurrency = currentConcurrency;
console.warn(`触发限流保护,并发降至${currentConcurrency}`);
}
}
});
// 每分钟重置计数器,尝试恢复并发
setInterval(() => {
if (rateLimitHits === 0 && currentConcurrency < 3) {
currentConcurrency++;
worker.concurrency = currentConcurrency;
}
rateLimitHits = 0;
}, RATE_LIMIT_WINDOW);
坑3:Docker沙箱的DNS解析
NetworkMode: 'none' 的容器完全没网络,但有些工具调用需要访问内网API。折中方案是用自定义网络+iptables规则,只允许访问内网IP段:
sql
docker network create --subnet=172.20.0.0/16 sandbox-net
iptables -I DOCKER-USER -s 172.20.0.0/16 -d 10.0.0.0/8 -j ACCEPT
iptables -I DOCKER-USER -s 172.20.0.0/16 -d 0.0.0.0/0 -j DROP
坑4:记忆系统的"幻觉放大"问题
Agent从长期记忆中检索到的信息如果本身有误(比如之前一次对话中模型的错误判断被存进了记忆),后续对话会反复引用这个错误信息。我们加了一个"记忆衰减"机制------长时间没被引用的记忆条目权重自动降低:
sql
-- 记忆衰减:每次检索命中时更新时间戳
UPDATE long_term_memory SET updated_at = NOW() WHERE id = $1;
-- 查询时加入时间衰减因子
SELECT *,
(1 - (embedding <=> $1::vector)) *
-- 30天内的记忆权重为1,之后线性衰减,90天后权重0.5
GREATEST(0.5, 1.0 - EXTRACT(EPOCH FROM NOW() - updated_at) / (90*86400) * 0.5)
AS weighted_similarity
FROM long_term_memory
WHERE user_id = $2
ORDER BY weighted_similarity DESC
LIMIT $3;
性能数据
在公司内网K8s集群(3个Worker Pod)上的实测数据:
| 指标 | 数值 |
|---|---|
| 简单对话延迟(P50 / P99) | 1.8s / 4.2s |
| 带工具调用延迟(P50 / P99) | 3.5s / 9.8s |
| 记忆检索延迟(P50 / P99) | 35ms / 85ms |
| Docker沙箱启动延迟 | ~200ms |
| 日处理消息量 | 400-600条 |
| 系统可用性(两周) | 99.7%(一次Redis连接中断导致5分钟不可用) |
| 单Worker内存占用 | 220MB |
| 月度API成本 | ¥400(Claude Sonnet为主) |
瓶颈在模型API调用,本地处理几乎不是问题。如果用国产模型(通义千问/DeepSeek),延迟能再降30-40%,成本降80%,但输出质量有差距,需要更多的prompt engineering。
写在最后
这套系统跑了两周,整体效果超出预期。最大的收获不是省了多少钱,而是验证了一件事:当前的大模型能力,已经足够覆盖企业内部大量的重复性信息处理工作。
OpenClaw的贡献在于,它用一个足够简洁的架构,把"AI Agent能干什么"这件事演示得很清楚。虽然它本身不适合直接用于生产,但它的架构设计------消息驱动、三层记忆、工具调用链------是一套值得学习的参考模型。
如果你也在考虑给团队搭一套类似的系统,希望这篇文章能让你少走一些弯路。
有问题欢迎评论区交流。项目后续如果开源了会更新链接。