在 HappyCapy 沙箱中搭建一个 7x24 运行的 Discord Bot

从零到稳定运行的完整过程,包括踩过的坑和最终的架构选择。

前言

我在 HappyCapy 上跑了一个 Discord Bot,已经稳定运行了好几周。

这件事的起点很简单——我需要一个随时能对话的 AI 助手,入口是 Discord。不是那种"发一条消息等一个回复"的一次性调用,而是一个有持续对话能力、能记住上下文、能看图、挂掉了还能自己爬起来的服务。

HappyCapy 给了我一个完整的 Linux 沙箱环境,里面预装了 Node.js 和 Claude CLI。一开始我以为只是拿来跑跑脚本,后来发现它完全可以当一台轻量服务器用。于是事情就变得有意思了。

这篇文章记录了从零到稳定运行的完整过程,包括踩过的坑和最终的架构选择。


面临的核心问题

HappyCapy 沙箱有几个特性需要正面应对:

  1. 沙箱会重启。 不是偶尔,是会周期性地重启。你的进程会被杀掉,内存里的状态全部丢失。
  2. 没有 systemd。 这不是一台传统的 VPS,你没有 systemd 或 init.d 来管理服务。
  3. 磁盘是持久的。 好消息是,工作目录下的文件在重启后会保留。
  4. 预装了有用的工具。 Node.js、Python、Claude CLI、ImageMagick 都可以直接用。

所以问题就变成了:怎么在一个"会重启的沙箱"里跑一个"不该停的服务"?


架构总览

最终方案是一个三层结构:

supervisord(进程守护)
    └── watchdog(健康监控 + 崩溃恢复)
          └── bot engine(Discord 连接 + AI 调用 + 消息处理)

再加一个独立的 auto-sync 守护进程,负责定时把运行时数据通过 git 推到 GitHub,解决持久化问题。

下面逐层说明。


第一层:supervisord —— 让进程在沙箱重启后自动拉起

HappyCapy 沙箱内置了 supervisord(Python 写的进程管理器)。这是整个架构的基础——沙箱每次重启时,supervisord 会自动启动,然后根据配置把你的进程拉起来。

配置文件放在 /home/node/supervisor.d/ 下面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[program:my_bot]
command=node watchdog.mjs
directory=/path/to/your/bot/services
autostart=true
autorestart=true
startretries=999
startsecs=5
stopwaitsecs=15
stopsignal=TERM
priority=700
stdout_logfile=/path/to/your/bot/logs/bot.log
stderr_logfile=/path/to/your/bot/logs/bot.log
stdout_logfile_maxbytes=10MB
stderr_logfile_maxbytes=10MB

environment=
    DISCORD_BOT_TOKEN="your-token",
    ANTHROPIC_BASE_URL="https://ai-gateway.happycapy.ai/api/v1/anthropic",
    ANTHROPIC_BEDROCK_BASE_URL="https://ai-gateway.happycapy.ai/api/v1/bedrock",
    ANTHROPIC_AUTH_TOKEN="your-auth-token",
    ANTHROPIC_MODEL="claude-sonnet-4-20250514",
    CLAUDE_CODE_USE_BEDROCK="1",
    CLAUDE_CODE_SKIP_BEDROCK_AUTH="1"

几个关键点:

  • autostart=true + autorestart=true:沙箱重启后自动拉起,进程崩溃后也自动重启。
  • startretries=999:允许大量重试。沙箱环境偶尔会有资源紧张的时刻,多试几次通常能恢复。
  • environment=:所有敏感 token 都写在这里。不用 .env 文件(虽然也可以),好处是 supervisord 启动时直接注入,不需要额外的 dotenv 加载逻辑。
  • 注意 AI Gateway 的 URL:HappyCapy 提供了自己的 AI Gateway (ai-gateway.happycapy.ai),你的 API 调用要走这个网关。后面会详细说。

supervisorctl 管理:

1
2
3
4
5
6
7
8
# 查看状态
supervisorctl -c /etc/supervisord.conf status

# 重启
supervisorctl -c /etc/supervisord.conf restart my_bot

# 查看日志
tail -f /path/to/your/bot/logs/bot.log

第二层:Watchdog —— 不只是重启,是健康监控

supervisord 能处理"进程挂了"的情况,但处理不了"进程还活着但已经卡死"的情况。比如 Node.js 事件循环阻塞、网络连接断开但进程没退出。

所以我加了一个 watchdog 层:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// watchdog.mjs
import { spawn } from 'child_process';
import { readFileSync, statSync } from 'fs';
import { resolve } from 'path';

const ENGINE_SCRIPT = resolve(import.meta.dirname, 'bot-engine.mjs');
const HEARTBEAT_FILE = '/tmp/bot-engine.heartbeat';
const PID_FILE = '/tmp/bot-engine.pid';

const config = {
  restartDelay: 3000,          // 崩溃后等 3 秒再重启
  maxRestarts: 10,             // 滑动窗口内最多重启 10 次
  maxRestartWindow: 300_000,   // 滑动窗口 5 分钟
  healthCheckInterval: 60_000, // 每 60 秒检查一次健康状态
  heartbeatTimeout: 120_000,   // 心跳超过 2 分钟未更新视为异常
  unhealthyThreshold: 3,       // 连续 3 次不健康才触发重启
};

let child = null;
let restartTimestamps = [];
let unhealthyCount = 0;

function start() {
  child = spawn('node', [ENGINE_SCRIPT], {
    stdio: 'inherit',
    env: process.env,
  });

  child.on('exit', (code) => {
    console.log(`[watchdog] engine exited with code ${code}`);
    scheduleRestart();
  });

  startHealthCheck();
}

function scheduleRestart() {
  // 滑动窗口防止无限重启
  const now = Date.now();
  restartTimestamps = restartTimestamps.filter(t => now - t < config.maxRestartWindow);

  if (restartTimestamps.length >= config.maxRestarts) {
    console.error('[watchdog] too many restarts, backing off');
    setTimeout(() => { restartTimestamps = []; start(); }, 60_000);
    return;
  }

  restartTimestamps.push(now);
  setTimeout(start, config.restartDelay);
}

function startHealthCheck() {
  setInterval(() => {
    try {
      const stat = statSync(HEARTBEAT_FILE);
      const age = Date.now() - stat.mtimeMs;

      if (age > config.heartbeatTimeout) {
        unhealthyCount++;
        if (unhealthyCount >= config.unhealthyThreshold) {
          console.log('[watchdog] heartbeat stale, restarting');
          child?.kill('SIGTERM');
          unhealthyCount = 0;
        }
      } else {
        unhealthyCount = 0;
      }
    } catch {
      // 心跳文件还没创建,正常(刚启动时)
    }
  }, config.healthCheckInterval);
}

start();

核心逻辑:

  • 引擎进程每 30 秒写一次心跳文件(就是 touch 一个文件,更新 mtime)。
  • watchdog 每 60 秒检查心跳文件的修改时间。如果超过 2 分钟没更新,计一次不健康。
  • 连续 3 次不健康才触发重启,避免偶发延迟导致误杀。
  • 滑动窗口限制重启频率:5 分钟内最多 10 次,超过就冷却 1 分钟再试。

引擎那边对应的心跳代码很简单:

1
2
3
4
5
6
// bot-engine.mjs 中
import { writeFileSync } from 'fs';

setInterval(() => {
  writeFileSync('/tmp/bot-engine.heartbeat', Date.now().toString());
}, 30_000);

这两层加在一起,处理了四种故障场景:

故障谁负责恢复
沙箱重启supervisord 自动拉起 watchdog
引擎崩溃退出watchdog 检测到 exit 事件,延迟重启
引擎卡死(进程还在但不响应)watchdog 通过心跳超时检测,强制重启
引擎疯狂崩溃(代码 bug)watchdog 滑动窗口限流,防止 CPU 打满

第三层:Bot 引擎 —— Discord + AI Gateway

Discord.js 连接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { Client, GatewayIntentBits, Partials } from 'discord.js';

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.DirectMessages,
    GatewayIntentBits.MessageContent,
    GatewayIntentBits.GuildMessages,
  ],
  partials: [Partials.Channel, Partials.Message],
});

client.on('ready', () => {
  console.log(`Connected as ${client.user.tag}`);
});

client.on('messageCreate', async (message) => {
  if (message.author.bot) return;
  if (!message.channel.isDMBased()) return;

  // 可选:只允许特定用户使用
  if (message.author.id !== process.env.DISCORD_OWNER_ID) {
    await message.reply('Access denied.');
    return;
  }

  await handleMessage(message);
});

client.login(process.env.DISCORD_BOT_TOKEN);

注意事项:

  • Partials.ChannelPartials.Message 是处理 DM(私信)必须的。Discord.js 默认不缓存 DM channel,不加 partial 会收不到私信。
  • MessageContent intent 需要在 Discord Developer Portal 里手动开启(Privileged Gateway Intents 下面)。
  • 我只处理 DM,不处理群消息。如果你要处理群消息,把 isDMBased() 检查去掉或改成检查 @ 提及。

调用 HappyCapy AI Gateway

这是最关键的部分。HappyCapy 提供了一个 AI Gateway,本质上是一个兼容 Anthropic/Bedrock 协议的代理。你不需要自己有 Anthropic API key——用 HappyCapy 给你的 auth token 就行。

有两种调用方式:

方式一:用 Claude CLI(适合纯文本)

HappyCapy 沙箱预装了 claude CLI。最简单的用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { spawn } from 'child_process';

function callClaude(prompt, systemPrompt) {
  return new Promise((resolve, reject) => {
    const args = ['-p', '--output-format', 'text'];
    if (systemPrompt) {
      args.push('--system-prompt', systemPrompt);
    }

    const child = spawn('claude', args, {
      env: {
        ...process.env,
        ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
        ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
      },
      stdio: ['pipe', 'pipe', 'pipe'],
    });

    let output = '';
    child.stdout.on('data', (data) => { output += data; });
    child.on('close', (code) => {
      if (code === 0) resolve(output);
      else reject(new Error(`claude exited with code ${code}`));
    });

    child.stdin.write(prompt);
    child.stdin.end();
  });
}

优点是简单,缺点是不支持图片。

方式二:直接调 API(支持多模态/图片)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
async function callClaudeAPI(messages, systemPrompt, model = 'claude-sonnet-4-20250514') {
  const baseUrl = process.env.ANTHROPIC_BEDROCK_BASE_URL;
  const authToken = process.env.ANTHROPIC_AUTH_TOKEN;

  const url = `${baseUrl}/model/${model}/invoke`;

  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${authToken}`,
    'anthropic-version': 'bedrock-2023-05-31',
  };

  // HappyCapy 可能需要自定义 header(用于会话追踪)
  if (process.env.ANTHROPIC_CUSTOM_HEADERS) {
    const colonIdx = process.env.ANTHROPIC_CUSTOM_HEADERS.indexOf(':');
    if (colonIdx > 0) {
      const key = process.env.ANTHROPIC_CUSTOM_HEADERS.slice(0, colonIdx);
      const value = process.env.ANTHROPIC_CUSTOM_HEADERS.slice(colonIdx + 1);
      headers[key] = value;
    }
  }

  const response = await fetch(url, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      anthropic_version: 'bedrock-2023-05-31',
      max_tokens: 4096,
      system: systemPrompt,
      messages,
    }),
    signal: AbortSignal.timeout(120_000), // 2 分钟超时
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status} ${await response.text()}`);
  }

  const data = await response.json();
  return data.content?.[0]?.text || '';
}

这个 Bedrock 兼容的端点支持 Anthropic Messages API 的完整功能,包括图片输入。

图片分析(多模态)

Discord 用户发图片时,消息的 attachments 里会包含图片 URL。流程是:下载 → 压缩(如果太大)→ 转 base64 → 发给 API。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
async function processImages(message) {
  const images = [];
  const BASE64_MAX = 4.5 * 1024 * 1024; // 留点余量,API 限制 5MB

  for (const [, attachment] of message.attachments) {
    if (!attachment.contentType?.startsWith('image/')) continue;

    const resp = await fetch(attachment.url);
    let buffer = Buffer.from(await resp.arrayBuffer());
    let mediaType = attachment.contentType;

    // 如果 base64 后会超限,用 ImageMagick 压缩
    if (buffer.length * 1.37 > BASE64_MAX) {
      const tmpIn = `/tmp/img_in_${Date.now()}`;
      const tmpOut = `/tmp/img_out_${Date.now()}.jpg`;
      writeFileSync(tmpIn, buffer);
      execSync(`convert "${tmpIn}" -resize "2048x2048>" -quality 85 "${tmpOut}"`);
      buffer = readFileSync(tmpOut);
      mediaType = 'image/jpeg';
      unlinkSync(tmpIn);
      unlinkSync(tmpOut);
    }

    images.push({
      type: 'image',
      source: {
        type: 'base64',
        media_type: mediaType,
        data: buffer.toString('base64'),
      },
    });
  }

  return images;
}

// 组装消息
async function handleMessage(message) {
  const images = await processImages(message);
  const text = message.content || '';

  const content = [...images];
  if (text) content.push({ type: 'text', text });
  else if (images.length > 0) {
    content.push({ type: 'text', text: '请分析这张图片。' });
  }

  const messages = [{ role: 'user', content }];
  const reply = await callClaudeAPI(messages, systemPrompt);

  // Discord 消息有 2000 字符限制,超长需要分片
  if (reply.length <= 2000) {
    await message.reply(reply);
  } else {
    const chunks = splitMessage(reply, 2000);
    for (const chunk of chunks) {
      await message.channel.send(chunk);
    }
  }
}

HappyCapy 沙箱里预装了 ImageMagick,所以 convert 命令直接可用。这个压缩步骤很重要——用户随手拍的照片经常超过 5MB,不压缩 API 会报错。


数据持久化:Git 即数据库

沙箱的磁盘是持久的,但我不想把数据的命运完全交给沙箱。解决方案:用 git 仓库存储所有运行时数据,定时推到 GitHub。

目录结构

my-bot/
├── services/
│   ├── bot-engine.mjs      # 主引擎
│   └── watchdog.mjs        # 看门狗
├── logs/
│   └── bot.log              # 运行日志
├── data/
│   ├── transcripts/         # 每日对话记录(JSONL 格式)
│   │   ├── 2026-03-01/
│   │   │   └── bot.jsonl
│   │   └── 2026-03-02/
│   │       └── bot.jsonl
│   └── state.json           # 运行时状态
├── scripts/
│   └── auto-sync.sh         # 自动同步脚本
└── package.json

对话记录持久化

每一轮对话都追加写入当天的 JSONL 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { appendFileSync, mkdirSync } from 'fs';
import { resolve } from 'path';

function saveTranscriptTurn(userMsg, botReply) {
  const today = new Date().toISOString().split('T')[0];
  const dir = resolve(DATA_ROOT, 'transcripts', today);
  mkdirSync(dir, { recursive: true });

  const entry = JSON.stringify({
    ts: Date.now(),
    user: userMsg,
    assistant: botReply,
  });

  appendFileSync(resolve(dir, 'bot.jsonl'), entry + '\n');
}

重启后从 transcript 文件恢复对话历史:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function restoreHistory() {
  const history = [];
  const today = new Date().toISOString().split('T')[0];
  const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];

  for (const date of [yesterday, today]) {
    const file = resolve(DATA_ROOT, 'transcripts', date, 'bot.jsonl');
    if (!existsSync(file)) continue;

    const lines = readFileSync(file, 'utf-8').trim().split('\n');
    const recent = lines.slice(-50); // 最多恢复 50 轮

    for (const line of recent) {
      try {
        const turn = JSON.parse(line);
        history.push({ user: turn.user, assistant: turn.assistant });
      } catch {}
    }
  }

  console.log(`Restored ${history.length} turns from transcript`);
  return history;
}

自动 Git 同步

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# auto-sync.sh - 每 30 分钟自动提交并推送运行时数据
INTERVAL=${1:-1800}

while true; do
  sleep "$INTERVAL"

  cd "$(dirname "$0")/.." || continue

  # 检查是否有变更
  CHANGES=$(git status --porcelain data/ logs/ 2>/dev/null)
  [ -z "$CHANGES" ] && continue

  # 拉取远端变更(避免冲突)
  git pull origin main --rebase --quiet 2>/dev/null || {
    echo "[auto-sync] pull failed, skipping this round"
    continue
  }

  # 只提交运行时数据,不提交代码
  git add data/ logs/
  git diff --cached --quiet && continue

  git commit -m "[auto-sync] runtime data $(date '+%m-%d %H:%M')" --quiet
  git push origin main --quiet 2>/dev/null || {
    echo "[auto-sync] push failed"
  }
done

在引擎启动时拉起这个脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// bot-engine.mjs 启动时
import { spawn, execSync } from 'child_process';

// 检查是否已经在运行
const existing = execSync(
  "ps aux | grep '[a]uto-sync\\.sh' | awk '{print $2}' | head -1",
  { encoding: 'utf-8' }
).trim();

if (!existing) {
  const syncProcess = spawn('bash', ['scripts/auto-sync.sh', '1800'], {
    detached: true,
    stdio: 'ignore',
    cwd: PROJECT_ROOT,
  });
  syncProcess.unref();
  console.log(`auto-sync started (PID: ${syncProcess.pid})`);
}

这样即使沙箱被回收,所有数据都在 GitHub 上。新环境启动时 git pull 一下就完全恢复。


对话历史管理:不只是存下来

一个 24 小时运行的 bot,对话历史会持续增长。如果不管理,context window 很快就会爆掉。

字节限制 + 自动压缩

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const HISTORY_BYTE_LIMIT = 200_000; // 约 200KB,大概 66K tokens

function getHistoryBytes(history) {
  return history.reduce((sum, turn) => {
    return sum + Buffer.byteLength(turn.user) + Buffer.byteLength(turn.assistant);
  }, 0);
}

async function compressIfNeeded(history) {
  const bytes = getHistoryBytes(history);
  if (bytes <= HISTORY_BYTE_LIMIT) return;

  // 保留最近的轮次,压缩旧的
  const keepRecent = 20;
  const oldTurns = history.splice(0, history.length - keepRecent);

  // 用 AI 生成摘要
  const summaryPrompt = `请简要总结以下对话的要点,保留关键信息和决策:\n${
    oldTurns.map(t => `用户: ${t.user}\n回复: ${t.assistant}`).join('\n---\n')
  }`;

  const summary = await callClaude(summaryPrompt);

  // 把摘要作为第一条插入
  history.unshift({
    user: '[Earlier conversation summary]',
    assistant: summary,
  });
}

这个方案让 bot 理论上可以无限对话——旧的内容被压缩成摘要,新的内容保持完整。


并发控制

多条消息同时到达时需要排队处理,不然可能出现竞态条件(比如两个 AI 调用同时读写对话历史)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MessageQueue {
  constructor(maxConcurrent = 2) {
    this.maxConcurrent = maxConcurrent;
    this.running = 0;
    this.queue = [];
  }

  async enqueue(task) {
    if (this.running >= this.maxConcurrent) {
      await new Promise(resolve => this.queue.push(resolve));
    }
    this.running++;
    try {
      return await task();
    } finally {
      this.running--;
      if (this.queue.length > 0) {
        this.queue.shift()();
      }
    }
  }
}

const messageQueue = new MessageQueue(2);

client.on('messageCreate', async (message) => {
  // ... 前置检查 ...

  await messageQueue.enqueue(async () => {
    await message.channel.sendTyping(); // 显示"正在输入"
    await handleMessage(message);
  });
});

maxConcurrent = 2 是实测出来的平衡点。设 1 的话,用户发了两条消息得等第一条完全处理完;设太大,沙箱内存撑不住(每个 AI 调用大概占 100-200MB)。


实际运行中踩过的坑

1. 僵尸进程导致内存耗尽

问题:某些情况下 AI 调用超时,但 claude CLI 的子进程没有被正确回收,变成僵尸进程。积累几个之后内存被吃光,新的调用直接 EPIPE 报错。

解决:给所有子进程加超时和强制回收。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const child = spawn('claude', args, { /* ... */ });

const timeout = setTimeout(() => {
  child.kill('SIGTERM');
  setTimeout(() => {
    if (!child.killed) child.kill('SIGKILL');
  }, 5000);
}, 120_000); // 2 分钟硬超时

child.on('close', () => clearTimeout(timeout));

2. 任务队列持久化的双刃剑

我实现了一个任务队列,把待处理的后台任务写到 JSON 文件里。初衷是好的——重启后能恢复未完成的任务。但如果有一批任务全部失败(比如 API 临时不可用),它们会被持久化到文件里,然后每次重启都疯狂重试,形成死循环。

解决:给持久化队列加最大重试次数,超过就丢弃。

1
2
3
4
5
function loadQueue() {
  const queue = JSON.parse(readFileSync(QUEUE_FILE, 'utf-8'));
  // 过滤掉重试次数过多的任务
  return queue.filter(task => (task.retries || 0) < 3);
}

3. Git auto-sync 和手动操作冲突

如果你在 HappyCapy 的 Claude 会话里手动编辑了文件但还没提交,auto-sync 的 git pull --rebase 会失败(因为有 unstaged changes)。

解决:auto-sync 遇到 pull 失败时直接跳过这一轮,等下次再试。不要尝试 stash/pop,太容易出问题。

4. Discord DM 的 partial 陷阱

没加 Partials.Channel 的话,bot 收到的 DM 消息 message.channel 可能是 null,导致 message.reply() 直接崩溃。这个错误只在 DM 场景下出现,guild 消息不受影响。

5. 图片去重

用户有时会在同一条消息里发多张一模一样的图片(比如不小心多选了)。如果不去重,每张都会被下载、转 base64、发给 API,浪费 token 且可能触发限流。

1
2
3
4
5
6
7
8
// 用 base64 前 1000 字符做简易指纹
const seen = new Set();
const uniqueImages = images.filter(img => {
  const fingerprint = img.source.data.slice(0, 1000);
  if (seen.has(fingerprint)) return false;
  seen.add(fingerprint);
  return true;
});

最终实现了什么

回顾一下,这套架构最终达到了这些效果:

  1. 7x24 运行。 沙箱重启、进程崩溃、卡死——都能自动恢复。三层防护(supervisord → watchdog → 心跳)覆盖了绝大多数故障场景。

  2. 有记忆的对话。 不是每次对话都从零开始。对话历史持久化到文件,重启后自动恢复最近 50 轮。超过限制的旧对话被 AI 自动压缩成摘要,理论上可以无限对话。

  3. 看得懂图片。 用户在 Discord 直接发图片,bot 自动下载、压缩、分析。实测 20MB 以内的图片都能处理。

  4. 数据不丢失。 所有运行时数据每 30 分钟通过 git 自动推到 GitHub。就算沙箱被彻底回收,拿到一个新沙箱 git clone 一下就能完全恢复。

  5. 资源可控。 并发限制在 2 个同时请求,对话历史字节数限制在 200KB。一个 2-4GB 内存的沙箱环境足够稳定运行。


给想尝试的人

如果你想在 HappyCapy 上搭类似的东西,几个建议:

  1. 从 supervisord 开始。 先确保你的进程能在沙箱重启后自动拉起,再考虑其他功能。这是一切的基础。

  2. 尽早加 auto-sync。 不要等数据积累了再想备份。第一天就把 git 自动同步跑起来。

  3. 善用 AI Gateway。 HappyCapy 的 AI Gateway 兼容 Bedrock 协议,你不需要自己的 API key。而且 claude CLI 直接可用,纯文本场景连 HTTP 调用都不用写。

  4. 注意内存。 沙箱内存有限,每个 claude CLI 进程大概占 100-200MB。控制并发数,及时回收子进程。

  5. 日志要写到文件。 console.log 在沙箱重启后就没了。supervisord 的 stdout_logfile 配置能自动帮你捕获标准输出到文件,但自己也应该有独立的日志文件。


这篇文章是对过去几周搭建过程的整理。实际动手的时候,很多问题是在运行中逐步发现、逐步解决的——不存在一开始就设计好完美架构这种事。先跑起来,再一层层加固,是最务实的路径。

如果你有问题或者想看更多细节,欢迎交流。

Discord: gffi0x