通过 Telegram 构建项目专属 AI 助手
专属于单个客户项目的 AI 助手,在结构上与面向全组织的 AI 工具截然不同:边界不同、记忆模型不同、密钥不同、部署生命周期也不同。本文介绍 tva 在实际工作中使用的项目专属 AI 助手模式:每个咨询项目配备一个 Telegram 机器人,每个机器人背后都有其独立的 Claude Code CLI 实例(持久化 OAuth)、独立的工作区,以及独立的参与者白名单。该模式可在不同项目间复制克隆,整个技术栈运行于单个 Docker 容器之内。
本文写作方式确保开发者(或 LLM)能够从头到尾线性阅读并从零复现整套配置。所有版本均已固定。每个配置选择均有说明。每项架构决策都列出了我们曾考虑的备选方案及取舍理由。
前置条件
- 一台安装了 Docker Engine 29.x 和 Docker Compose v2 的 Linux 服务器(我们运行在 Hetzner Cloud VPS 上;任何支持容器的主机均可)
- Anthropic Pro 或 Max 订阅(本文使用 OAuth 订阅认证,非 API key 计费)
- 通过
@BotFather注册的 Telegram 机器人及其 token - 所有希望加入助手的用户的 Telegram 用户 ID(通过
@userinfobot获取) - 笔记本电脑上的浏览器,用于一次性 OAuth 授权流程
- 对本实例所服务的咨询项目有清晰的认知
本文将构建的内容
- 一个将 Python Telegram 机器人与 Claude Code CLI 合并于同一 runtime 的单 Docker 容器
- 对接 Claude Max 订阅的 OAuth 认证,凭据在容器重启后持久保存
- 一个白名单 middleware,对白名单外的任何消息(无论私聊还是群组)均静默丢弃
- 一个持久化工作区,助手在其中长期维护自己的 CLAUDE.md 和 notes/ 文件
- 将 Telegram 附件(PDF、图片、语音等)接收至
/workspace/incoming/的文件接收功能 - 以 Markdown 格式保存的对话日志,人类可读,可通过 Read-Tool 供助手访问
- 一个身份标识层(
HERMES_PROJECT_NAME、HERMES_INSTANCE_ID),只需修改两个环境变量即可将整套栈克隆至其他项目
项目实例模式:为何「一项目一实例」优于组织级共用
构建内部 AI 工具时,最直觉的做法是做成全组织共用:一个机器人、一个工作区、所有团队成员都有权限。这种方式在低风险场景下可行(比如汇总文档的 Slack 机器人、内部问答工具),但在咨询工作中很快就会失效——每个项目都有自己的保密边界、各自的利益相关方、独立的知识库,以及各自的截止期限。
项目实例模式将这一逻辑反转:一个项目,一个机器人;一个项目,一个工作区;一个项目,一份白名单。助手的记忆被限定在项目范围内,而非绑定在运营者身上。项目结束后,该实例可以归档或销毁,完全不影响其他实例。
具体而言:
- Telegram 机器人使用项目专属用户名(例如
@some_project_assistant_bot),在 BotFather 中单独注册 - Docker 容器使用项目专属名称(例如
some-project-assistant),运行于项目专属目录(/opt/some-project-assistant/) - OAuth 会话仅限于本实例——若同时运行多个实例,最好使用独立的 Anthropic 账号,以避免 refresh token 轮换冲突
/workspace/CLAUDE.md中的工作区内容仅包含该项目的背景说明- 白名单中仅包含该项目的参与者
两个环境变量使整套栈可模板化克隆:HERMES_PROJECT_NAME(显示名称,用于系统提示词和 /help 输出)和 HERMES_INSTANCE_ID(用于目录路径和 Claude 会话标识符的 slug)。为新客户克隆栈时,只需修改这两个环境变量、在 BotFather 注册新机器人、执行一次新的 OAuth 登录、填写工作区模板,整套代码库保持二进制一致。
编写代码前需做出的七项架构决策
这套栈之所以小巧、行为可预测,正是因为我们在写第一行代码之前就做出了七个深思熟虑的决策。每个决策都有备选方案,而备选方案同样重要。如果你的场景与我们不同,在某个决策点选择另一条路,你将得到一套不同的(也许更合适的)技术栈。我们列出各项决策、曾考虑的备选方案,以及驱动我们做出选择的权衡因素。
决策一:记忆粒度
可选方案:全局记忆(所有对话共用一个 Claude 会话,助手跨私聊和群组记住一切);或按对话隔离记忆(每个对话有独立的会话,私聊与群组之间有严格的隐私边界)。
我们选择了全局记忆。理由是:咨询助手能够跨对话关联信息是有益的——在某次私聊中讨论过的供应商评估,可以在该供应商合同的群组讨论中被调用。按对话隔离记忆会迫使运营者反复输入上下文,助手的表现也会显得割裂。
代价是真实存在的:私聊与群组之间没有隐私边界。在私聊中提及的任何内容,理论上都可能出现在群组回复中。这是一个经过明确记录的主动选择,而非副作用。对于不同使用场景(例如个人披露信息必须保密的 HR 机器人),按对话隔离记忆才是正确答案。
实现方式:Claude Code CLI 的 --continue 标志,配合固定工作目录。会话文件位于 ~/.claude/projects/-workspace/sessions/<auto-id>.jsonl,通过 bind-mount 持久化,在后续每次从同一工作目录调用 claude 时恢复。
决策二:订阅 OAuth 还是 API key
Claude Code CLI 有两种驱动方式:使用运营者的 Pro/Max 订阅(基于 OAuth,无按次计费)或使用 Anthropic API key(按 token 付费)。默认为订阅模式。陷阱在于:如果父环境中设置了某些环境变量,CLI 会静默切换到 API key 计费模式。
根据 Anthropic 的认证文档,解析优先级从高到低依次为:Bedrock/Vertex/Foundry 云提供商标志,然后是 ANTHROPIC_AUTH_TOKEN,再是 ANTHROPIC_API_KEY,然后是 apiKeyHelper,接着是 CLAUDE_CODE_OAUTH_TOKEN,最后才是通过 /login 建立的订阅 OAuth。如果任何更高优先级的选项被设置——即使在某些 shell 中只是设为空字符串——CLI 也不会回退到订阅认证。
为确保仅使用 OAuth,需在 compose 文件的 environment: 块中将全部六个环境变量显式设为空字符串。这是一种防御性做法,代价极低。
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
权衡:订阅认证的 refresh token 采用单次使用轮换机制,如果同一 Anthropic 账号同时被容器和运营者的笔记本电脑使用,两者并行 refresh 时会产生冲突,导致随机登出。对于专用助手,使用专用 Anthropic 账号是更干净的方案。如需对比不同 AI 工具及其认证模型,可参阅我们对 Claude Code、Cursor 及其他 CLI agent 的横向比较。
决策三:单容器还是双容器
机器人需要 Telegram 框架(Python、aiogram);Claude 引擎需要 Node.js 和 @anthropic-ai/claude-code CLI。你可以将它们部署为两个容器(例如机器人运行在 Python 容器中,claude 运行在 Node.js 容器中,通过 IPC 通信),也可以合并为一个容器。
双容器方案在结构上更整洁,但引入了 IPC 问题。机器人需要将 claude 作为子进程调用,这意味着它需要访问另一个容器的 Docker socket(存在权限提升风险),或者自定义基于文件的 IPC 层(引入额外延迟和代码复杂度)。两者都不理想。
单容器方案以容器纯粹性换取了运维简洁性。一个镜像、一个 OAuth 会话、一组环境变量、一套 bind-mount 布局。镜像约为 700 MB(而非约 120 MB),但磁盘空间几乎不是瓶颈。
我们选择单容器。Dockerfile 依次安装 Python 栈(slim 基础镜像 + pip)和 Node.js 栈(NodeSource keyring + claude-code),暴露单一入口点,机器人通过 asyncio.create_subprocess_exec 调用 claude。无 IPC,无 socket 代理,无跨容器网络。
决策四:工作区初始化
助手需要知识库。可选方案:预置项目上下文(运营者无需通过对话逐条输入事实),或从零开始(助手纯粹从交互中学习)。
我们选择预置。工作区模板位于 templates/workspace-CLAUDE.md.template,包含以下占位符区域:运营者简介、参与者及其角色、项目背景、语言规范,以及助手应如何随时间维护笔记的说明。新实例引导时,模板被复制至 data/workspace/CLAUDE.md,占位符随即填入。
助手随后通过 Write 和 Edit 工具自行维护该文件。当你纠正它(「这个客户不是这样使用这个术语的」),它可以更新工作区文件,使纠正在后续会话中持续生效。结合全局会话记忆,助手拥有两层状态:短期状态存于 Claude 会话,长期状态存于工作区文件。两者均通过 bind-mount 在容器重启后持久保留。
决策五:群组行为与隐私模式
Telegram 机器人在群组中有一项隐私模式设置:默认情况下,机器人只能看到直接发给它的消息(命令、@提及、回复)。其他群组消息不会被传递给机器人。你可以在 BotFather 中禁用此模式(/setprivacy → Disable),之后机器人将能看到它所在的每个群组中的所有消息。
对于需要从群组讨论中学习的咨询助手,「禁用隐私模式」是正确的选择。但这引出一个后续问题:机器人如何决定哪些消息需要回复,哪些只需记录?
我们的触发逻辑:在私聊中,每条消息都会得到回复。在群组中,机器人仅响应以下情况:(a) 显式 @提及机器人,(b) 回复机器人自身之前的消息,或 (c) 消息中包含独立单词「Hermes」(大小写不敏感,按词边界匹配)。其他所有消息记录至 /workspace/conversations/chat-<id>.md,但不触发 Claude 调用。
这意味着助手具备对群组上下文的读取能力(需要背景时可通过 Read 工具查阅日志文件),但不会产生噪音。对话日志同时也是一份有用的人类可读档案——运营者可通过 cat 命令查看项目历史。
决策六:响应用户体验
当涉及工具调用(网络抓取、文件读取、多步推理)时,Claude 的响应可能需要 5 到 30 秒。可选方案:缓冲式(等待完整响应后一次性发送一条消息)或流式(随 token 生成逐步编辑单条消息)。
流式更酷,但更复杂:Telegram 对单条消息的编辑频率有限制,但确切上限没有以单一数字形式记录在案。总体发送频率(Telegram 公开限制:全局约每秒 30 条,每个对话每秒不超过 1 条,每个群组每分钟 20 条)提供了大致上限。逐 token 编辑流很快就会触发限流。实现起来需要分块聚合、处理 Claude 流式 JSON 输出中的部分消息,以及在编辑受限时的优雅降级。
我们选择缓冲式。在响应生成过程中,机器人每隔 5 秒发送一个 typing 聊天动作,让用户在 Telegram 客户端看到「正在输入」。响应准备完毕后,以一条或多条消息发出。超过 4000 字符的响应会在限制前的最后一个段落边界处自动分割,消息间有 0.3 秒的停顿,以保持在 Telegram 的发送频率限制内。
决策七:严格白名单作为外层边界
任何找到机器人用户名的人都可以触达它。如果不加过滤,随机用户会发现该机器人并尝试发送命令。对于咨询助手而言,这是不可接受的:助手持有运营者的 OAuth 凭据,可访问工作区,可发起工具调用。你不希望陌生人介入这个闭环。
白名单以 aiogram 的 outer-middleware 形式实现,挂载在 update 层级——这是最高拦截点,在 filter 解析和 handler 查找之前生效。检查基于 event_from_user.id(Telegram 用户的数字 ID,即使用户修改用户名也保持稳定)。白名单成员通过 CSV 格式的环境变量(HERMES_ALLOWED_USERS)配置。如果发送者不在集合中,middleware 直接返回 None,不调用 handler:仅在 debug 级别记录一条丢弃事件,不写入对话日志,不发起 Claude 调用,不产生任何回复。
这也是进行「运营者必须在白名单中」校验的正确位置:settings 加载器(使用 pydantic-settings)在启动时验证 HERMES_OPERATOR_ID是否包含在 HERMES_ALLOWED_USERS 中。配置错误会立即导致容器崩溃,而不是静默地将运营者锁在外面。
容器技术栈
七项决策确定之后,技术栈自然而然浮现出来。以下是完整的 docker-compose.yml:
services:
hermes:
build: ./hermes
container_name: project-assistant
restart: unless-stopped
init: true
stop_grace_period: 15s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 512
env_file:
- .env
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
DISABLE_AUTOUPDATER: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TERM: xterm-256color
volumes:
- ./data/claude:/home/hermes/.claude
- ./data/claude.json:/home/hermes/.claude.json
- ./data/bot:/data
- ./data/workspace:/workspace
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
几个不容易从字面看出的关键选择:
init: true— 以 tini 作为 PID 1 运行,使 Python 进程能干净地接收 SIGTERM。没有这项设置,docker compose stop会等待 10 秒后发送 SIGKILL,导致机器人会话未正常关闭。stop_grace_period: 15s— 略长于 aiogram 默认的polling_timeout10 秒。这为关闭钩子留出时间以干净地关闭 Telegram 会话,防止容器重启速度快于 Telegram 释放上一个长轮询连接时出现TelegramConflictError: terminated by other getUpdates request。cap_drop: ALL和no-new-privileges:true— 容器不需要任何 Linux capabilities,也不需要权限提升。两者均默认收紧。- 未使用
read_only: true— Claude Code 会写入~/.claude/、~/.npm/,偶尔也会写入/tmp/用于自更新。只读根文件系统需要大量 tmpfs 挂载来补偿,安全收益不值得。 - 两个文件挂载 —
./data/claude.json:/home/hermes/.claude.json挂载的是具体文件(而非目录)。该文件必须在compose up之前在宿主机上存在,并以{}初始化,否则 Claude Code 在启动时会抛出 JSON 解析错误。
Dockerfile 将两个 runtime 合并在一起:
FROM python:3.13-slim-trixie
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl file git gnupg locales poppler-utils tmux \
&& locale-gen en_US.UTF-8 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN userdel -r ubuntu 2>/dev/null || true \
&& useradd -m -u 1000 -s /bin/bash hermes \
&& mkdir -p /data /workspace \
&& chown -R hermes:hermes /data /workspace /app
COPY --chown=hermes:hermes src/ ./src/
USER hermes
WORKDIR /app
CMD ["python", "-u", "-m", "src.main"]
需要注意的固定版本:
python:3.13-slim-trixie— Debian 13 基础镜像,当前稳定版aiogram>=3.28,<4.0— 机器人框架(撰写本文时锁定为 3.28.x,当前最新版为 3.28.2)pydantic-settings>=2.14,<3.0— settings 加载器structlog>=25.4,<26.0— JSON 日志@anthropic-ai/[email protected]— 通过 npm global 从 NodeSource Node.js 20 安装poppler-utils— 提供 pdftotext,作为 Claude PDF 解析不适用时的 Read-tool 回退方案
此 Dockerfile 中有两点需要注意:
userdel -r ubuntu这一行。Python slim 基础镜像在 Debian 13 上不自带默认 UID-1000 用户,但如果你切换到自带该用户的基础镜像(某些 Ubuntu 衍生版),useradd -u 1000将会失败。始终先删除已有的 UID-1000 用户。- NodeSource keyring 模式。我们避免使用
curl | bash安装脚本——该方式已被弃用且不可复现。使用 keyring +nodistro代号的方式可复现,且便于审计。
如需深入了解容器加固如何融入多服务生产环境,请参阅我们关于在生产环境运行百余个 Docker 容器的操作详解。
Python 技术栈:五个承担主要工作的模块
机器人代码拆分为职责单一的模块。每个模块都不大,最大的约 200 行。
settings.py— 使用 pydantic-settings 的BaseSettings,并为逗号分隔的白名单配置了自定义BeforeValidator。该 validator 处理了一个边界情况:pydantic-settings 会将裸整数进行 JSON 解码(如HERMES_ALLOWED_USERS=12345中的单元素白名单会变成int而非list[int]),validator 将其转换为单元素列表。middleware.py— 以dp.update.outer_middleware形式实现的AllowListMiddleware。对data.get("event_from_user")的检查使用了 aiogram 内置的用户上下文提取。trigger.py—is_trigger(message, bot_id, bot_username)。返回(True, "DM" | "@mention" | "text_mention" | "reply" | "keyword")或(False, None)。关键词匹配使用词边界正则(\bhermes\b),避免子字符串误触发。conversation_log.py— 将消息以只追加方式写入 Markdown 日志,路径为/workspace/conversations/chat-<id>.md。入站和出站消息均被记录。file_intake.py— 处理八种 Telegram 附件类型(document、photo、video、audio、voice、animation、sticker、video_note)。下载至/workspace/incoming/chat-<id>/<timestamp>-<name>,硬限制为 20 MB(Telegram 机器人 API 下载上限)。hermes_engine.py— 封装 Claude Code CLI 子进程。使用asyncio.create_subprocess_exec,设置cwd=/workspace并在首次调用后使用--continue以维护全局会话。response_pipeline.py— 整合 typing 指示器刷新、自动分割和消息间延迟。handlers.py— 三个命令 handler(/ping、/status、/help)和一个默认消息 handler,负责运行触发检查并分派至引擎。
Claude CLI 的调用是整套机制的核心。以下是完整的子进程调用代码:
cmd = [
"claude",
"--print",
"--add-dir", "/workspace",
"--dangerously-skip-permissions",
"--append-system-prompt", _build_system_prompt(),
]
if SESSION_MARKER.exists():
cmd.append("--continue")
cmd.append(_build_user_prompt(text, ctx))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd="/workspace",
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
SESSION_MARKER.touch(exist_ok=True)
return stdout.decode("utf-8").strip()
系统提示词设定了助手的角色定位、验证规范(不要妄下断言,先通过工具验证)、学习循环(将新事实写入 /workspace/CLAUDE.md 或 notes/)、Telegram 输出格式(纯文本,不使用 Markdown——Telegram 的默认模式无法可靠渲染 Markdown),以及关于对话日志可按需读取的说明。
用户提示词将实际消息与上下文元数据一并封装:消息来源(与 X 的私聊,或包含成员 A、B、C 的群组 Y)、发送者身份、时间戳、触发原因,以及该对话的日志文件路径。这使助手在回复前能自行决定是否需要查阅群组上下文。
分步指南:引导新实例
假设代码库已存于 Git 仓库,且服务器已安装 Docker,以下是完整的引导流程。请将 <instance-id> 和 <project-name> 替换为你自己的值。
步骤一:通过 BotFather 注册机器人。
- 在 Telegram 中向
@BotFather发消息 /newbot→ 设置名称和用户名(例如some_project_assistant_bot)- 保存 BotFather 返回的 token
/setprivacy→ 选择你的机器人 → Disable(让机器人能看到所有群组消息,而非仅命令和提及)- 可选:通过
/setcommands设置ping、status、help命令
步骤二:克隆仓库并在服务器上准备目录。
sudo mkdir -p /opt/<instance-id>
sudo chown $USER /opt/<instance-id>
git clone <repo-url> /opt/<instance-id>
cd /opt/<instance-id>
mkdir -p data/claude data/bot data/workspace/{notes,conversations,incoming}
touch data/claude.json
sudo chown -R 1000:1000 data/
chown 步骤至关重要,却容易被忽略。Docker 在创建缺失的 bind-mount 源目录时默认归 root 所有;如果跳过 chown,容器用户(UID 1000)将无法写入,导致 OAuth 登录无声失败。通过 stat -c "%a %u:%g" data/claude 验证——应显示 1000:1000,而非 0:0。
步骤三:初始化工作区。
cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# 打开文件并替换占位符:
# {{HERMES_PROJECT_NAME}}、{{OPERATOR_NAME}}、{{CLIENT_LEGAL_ENTITY}} 等
步骤四:配置密钥。
cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# 编辑 .env:TELEGRAM_BOT_TOKEN、HERMES_ALLOWED_USERS、
# HERMES_OPERATOR_ID、HERMES_PROJECT_NAME、HERMES_INSTANCE_ID
步骤五:前置条件——向机器人发送 /start。
Telegram 不允许机器人主动向用户发送私信,除非该用户曾至少发起过一次对话。在首次启动容器之前,运营者必须在 Telegram 中打开机器人并发送 /start。机器人不会回复(没有为 /start 注册 handler),但 Telegram 会在内部建立私聊频道。跳过这一步,首次引导私信将抛出 TelegramForbiddenError。
步骤六:构建并启动容器。
docker compose build hermes
docker compose up -d hermes
sleep 5
docker compose logs --tail=20 hermes
你应该看到如下 JSON 日志序列:startup(包含项目名称和 allowed_users 数量)、polling_initialized(包含 drop_pending=true)、onboarding_sent(发给运营者的第一条私信),然后是 aiogram 的轮询启动事件。
步骤七:OAuth 登录。
这是一次性交互步骤。在足够宽的终端窗口中执行(250 个字符以上——否则 URL 会折行,导致 Cloudflare 以 Unknown scope: us 拒绝认证):
docker exec -it project-assistant tmux new-session -s claude
# 在容器内:
claude
# 在 claude REPL 中:
/login
# 选择:Claude Pro or Max subscription
# 复制显示的 URL,在浏览器中打开,登录后将授权码粘贴回终端。
/status # 应显示 "Subscription",而非 "API key"
/quit
# 用 Ctrl-b d 分离 tmux,然后退出 docker exec。
步骤八:在 Telegram 中进行冒烟测试。
- 私信机器人:
/ping→pong - 私信机器人:
/status→ 诊断输出(白名单数量、会话消息数、日志数量) - 私信机器人:一个自然语言问题——助手应通过 Claude 引擎给出回复
- 发送一个小 PDF 或图片并附加说明——助手应能引用其内容
- 连续发送两条消息,第二条引用第一条的内容——多轮记忆应保持连贯
步骤九:添加其他参与者(就绪时)。
- 每位参与者通过
@userinfobot获取自己的 Telegram 用户 ID - 更新
.env:HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id> docker compose restart hermes- 创建一个 Telegram 群组,邀请所有成员和机器人,通过
/ping@some_project_assistant_bot测试
运维说明与已知风险
该技术栈运行稳定,但有几个问题值得关注。
OAuth refresh 时的 Cloudflare WAF 拦截。 Anthropic 的认证端点位于 Cloudflare 之后。anthropics/claude-code 仓库中有一个已知问题:Cloudflare 会将某些服务器 IP 识别为无界面 Linux 系统,并永久封锁 OAuth refresh。有报告显示封锁持续数周。恢复路径是从其他 IP(住宅 IP、VPN)重新认证。缓解措施包括避免自定义 user-agent 和激进的重试循环,以及若在高风险 IP 段运营,考虑将 claude setup-token(从已认证机器生成的一年期 token,通过 CLAUDE_CODE_OAUTH_TOKEN 注入容器)作为备用方案。
Refresh token 单次使用轮换。 Anthropic 的 OAuth refresh token 为单次使用。如果两个客户端(例如你的笔记本和容器)共用同一账号并同时 refresh,先完成的一次会使另一方失效。实际建议:每个助手实例使用专用的 Anthropic 账号。如果无法做到,接受偶发的重新登录,不要同时在笔记本上运行 Claude Code 和助手。
空 claude.json 陷阱。 如果 data/claude.json 在容器首次启动时是零字节文件,Claude Code 会抛出 Configuration Error: invalid JSON, Unexpected EOF。请用 echo "{}" > data/claude.json 初始化,而非使用 touch。该错误在 REPL 中可恢复(「Reset with default configuration」),但避免这个摩擦更好。
OAuth URL 折行问题(我们自己遇到的)。 OAuth URL 约 530 个字符。在普通宽度的终端中,它会折行。复制折行输出时换行符也会一并复制,URL 编码后 scope 参数看起来像 user:inference us\ner:profile。Cloudflare 将 us 视为一个 scope,拒绝并返回 Invalid OAuth Request — Unknown scope: us。撰写本文时,这一问题尚未作为 upstream issue 被追踪——我们自己踩到了这个坑。在启动 claude 之前将终端宽度调至 250 个字符以上可以避免此问题,或者在粘贴到浏览器地址栏后手动删除 URL 中的换行符。
Ubuntu UID 1000 冲突。 某些现代 Ubuntu 基础镜像——尤其是 Canonical OCI 重构后的 ubuntu:24.04(Noble)——自带一个 UID 1000 的默认 ubuntu 用户。如果将 Dockerfile 基础镜像从 python:3.13-slim-trixie(不带默认 UID-1000 用户)切换至带有该用户的镜像,useradd -u 1000 hermes 将因 UID 1000 is not unique 而失败。Dockerfile 中出于防御目的在 useradd 之前添加了 userdel -r ubuntu 2>/dev/null || true。
对话日志可能持续增长。 /workspace/conversations/ 中的只追加日志随每条消息增长。经过数月活跃使用,单个文件可能达到兆字节级别。目前没有内置的轮换机制。如有需要,可添加定时任务归档超过 N 天的日志,或按月分割。
关于自托管服务的更广泛运维问题及故障应对,请参阅我们关于自托管服务灾难恢复的文章。
刻意不在范围内的内容
构建内部 AI 工具时,往往会有不断叠加功能的冲动。我们刻意保持这套栈的精简。以下内容明确不在本文所描述版本的范围内,我们主动选择暂不添加:
- RAG / 向量数据库。 助手的知识存储于 Markdown 文件(
CLAUDE.md和notes/)及对话日志中,通过 Read-tool 调用处理检索。对于单项目范围而言,这已足够。一旦工作区超过一定规模,真正的 RAG 层(例如 PostgreSQL + pgvector)才会变得必要,在此之前则是过度设计。 - 音频转录。 语音笔记已被下载并记录元数据,但助手尚不能转录它们。添加 Whisper 或类似流水线约需半天工作量,推迟至有需要时再做。
- 健康检查端点。 容器没有 HTTP 服务器,没有可抓取的内容。Docker 的重启策略加上日志监控已覆盖大多数故障模式。
- 流式响应。 见上文决策六。
- 单容器多租户。 每个项目独立一个容器。这是刻意为之——见上文项目实例模式的说明。
延迟实现的功能不是缺陷,而是选择,它们减少了现有功能的故障面。关于保持 AI 工具专注性的思考,请参阅为特定领域工作流构建 AI agent skills 和 Claude Skills 概览。
为下一个项目克隆
两个环境变量的设计在此体现出价值。要启动一个新实例:
- 将仓库克隆至
/opt/<new-instance-id> - 在
.env中修改HERMES_PROJECT_NAME和HERMES_INSTANCE_ID - 在 BotFather 注册新机器人(一次性)
- 用新项目的上下文填写工作区模板(一次性)
- 在新容器内执行 OAuth 登录(一次性,最好使用独立的 Anthropic 账号)
docker compose up -d
各实例的代码二进制一致。唯一有差异的是两个环境变量、机器人凭据、工作区内容和 OAuth 会话。
你可以在同一台服务器上运行多个实例。每个实例有独立的目录、独立的容器名称、独立的 bind-mount 树。磁盘占用大致为:700 MB 的镜像(得益于 Docker 的层缓存,各实例共享)加上每个实例的工作区增量(通常在使用数月后达到数十 MB)。
在工具链中的定位
项目专属 AI 助手并非通用 AI 编码工具的替代品。我们在开发工作中仍然直接使用 Claude Code、Cursor 和 Gemini CLI。助手服务于咨询场景:项目记忆、文档分析、进展更新、在确定项目范围内的即时调研。它与其他 AI 工具并行运行,而非取而代之。
这一模式也不是 Claude.ai 或 ChatGPT 等对话式 AI 产品的替代品。对于一次性任务、个人问题和通用知识工作,那些工具才是正确的选择。项目专属助手适合的场景是:项目有自己的边界、自己的参与者,以及不断演进的知识库——你不想每次需要引用时都把这些内容重新倾倒进一个通用聊天机器人中。
如果你正在考虑为自己的咨询实践、代理机构或内部团队构建类似的工具,上面的技术栈是一个合理的起点。Dockerfile、compose 文件和模块结构都可以按本文完整复现。决策已有文档记录。如果你的场景在七项决策的任何一项上与我们不同,可以据此分叉和调整——代码足够短,重写一个模块并不复杂。欢迎联系我们交流经验,或在具体实现上寻求协助。