前言
我在 HappyCapy 上跑了一个 Discord Bot,已经稳定运行了好几周。
这件事的起点很简单——我需要一个随时能对话的 AI 助手,入口是 Discord。不是那种"发一条消息等一个回复"的一次性调用,而是一个有持续对话能力、能记住上下文、能看图、挂掉了还能自己爬起来的服务。
HappyCapy 给了我一个完整的 Linux 沙箱环境,里面预装了 Node.js 和 Claude CLI。一开始我以为只是拿来跑跑脚本,后来发现它完全可以当一台轻量服务器用。于是事情就变得有意思了。
这篇文章记录了从零到稳定运行的完整过程,包括踩过的坑和最终的架构选择。
面临的核心问题
HappyCapy 沙箱有几个特性需要正面应对:
- 沙箱会重启。 不是偶尔,是会周期性地重启。你的进程会被杀掉,内存里的状态全部丢失。
- 没有 systemd。 这不是一台传统的 VPS,你没有 systemd 或 init.d 来管理服务。
- 磁盘是持久的。 好消息是,工作目录下的文件在重启后会保留。
- 预装了有用的工具。 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.Channel 和 Partials.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;
});
|
最终实现了什么
回顾一下,这套架构最终达到了这些效果:
7x24 运行。 沙箱重启、进程崩溃、卡死——都能自动恢复。三层防护(supervisord → watchdog → 心跳)覆盖了绝大多数故障场景。
有记忆的对话。 不是每次对话都从零开始。对话历史持久化到文件,重启后自动恢复最近 50 轮。超过限制的旧对话被 AI 自动压缩成摘要,理论上可以无限对话。
看得懂图片。 用户在 Discord 直接发图片,bot 自动下载、压缩、分析。实测 20MB 以内的图片都能处理。
数据不丢失。 所有运行时数据每 30 分钟通过 git 自动推到 GitHub。就算沙箱被彻底回收,拿到一个新沙箱 git clone 一下就能完全恢复。
资源可控。 并发限制在 2 个同时请求,对话历史字节数限制在 200KB。一个 2-4GB 内存的沙箱环境足够稳定运行。
给想尝试的人
如果你想在 HappyCapy 上搭类似的东西,几个建议:
从 supervisord 开始。 先确保你的进程能在沙箱重启后自动拉起,再考虑其他功能。这是一切的基础。
尽早加 auto-sync。 不要等数据积累了再想备份。第一天就把 git 自动同步跑起来。
善用 AI Gateway。 HappyCapy 的 AI Gateway 兼容 Bedrock 协议,你不需要自己的 API key。而且 claude CLI 直接可用,纯文本场景连 HTTP 调用都不用写。
注意内存。 沙箱内存有限,每个 claude CLI 进程大概占 100-200MB。控制并发数,及时回收子进程。
日志要写到文件。 console.log 在沙箱重启后就没了。supervisord 的 stdout_logfile 配置能自动帮你捕获标准输出到文件,但自己也应该有独立的日志文件。
这篇文章是对过去几周搭建过程的整理。实际动手的时候,很多问题是在运行中逐步发现、逐步解决的——不存在一开始就设计好完美架构这种事。先跑起来,再一层层加固,是最务实的路径。
如果你有问题或者想看更多细节,欢迎交流。
Discord: gffi0x