让 Hermes 风格智能体随项目持续成长的调优方法
基于消息频道构建的项目专属 AI 助手——无论是 Telegram、Discord、Slack,还是通过轻量网关接入的邮件——在多用户群聊中的行为与单人私聊截然不同。将团队引入 Bot 会暴露一类私聊测试路径永远无法触达的 Bug:关键词触发时的空回复崩溃、进度提示触发时机失准、并发触发产生的竞争条件,以及智能体自身对话日志中的记忆漂移。
本文整理了我们应用于轻量 Hermes 风格助手的八个调优模式——该助手以 Claude Code CLI subprocess 方式构建,而非完整的 NousResearch 框架。每个模式对应一个具体问题,并附有修复代码。这些模式同样适用于两种代码库。
本文解决的问题
- 关键词触发时 LLM 返回空 stdout,导致通用
Unexpected error回复 - 安抚文本在每次短回复时都触发,因为阈值低于典型响应延迟
- subprocess 在流式传输途中卡死且永不返回,没有超时机制清理
- 两名群成员同时触发智能体时,对同一 session 文件产生竞争条件
- 智能体运行足够长时间后,stderr 管道因 64 KB 缓冲区填满而静默死锁
- Bug 回退消息写入对话日志,智能体在下次读取时将其当作正常行为
前置条件
本文假设你已采用《通过 Telegram 构建项目专属 AI 助手》中的架构:每个项目一个 Docker container,非 root 用户,持久化 OAuth 认证的 Claude Code CLI,aiogram Bot 封装层,以及挂载 workspace 和 session 状态的 bind-mount 卷。部分模式依赖特定版本:
- Claude Code CLI 2.1.139 或更高版本(支持
--output-format stream-json --verbose --include-partial-messages) - aiogram 3.28.2 或更高版本(支持
ChatActionSender、message.react()、ReactionTypeEmoji) - Python 3.13 基础镜像
- Bot 已加入 Telegram 群聊,且具备
can_react_to_messages: true权限
如需为同一项目配置邮件频道,请参阅《为项目配置带 DKIM、SPF 和 DMARC 的专属邮箱》。
什么是 Hermes 风格智能体
Hermes 风格这一模式得名于 NousResearch 的开源框架。它与无状态聊天机器人有三个本质区别:
- 持久记忆。智能体在每轮对话之间读写磁盘上的 workspace,上下文在 container 重启后依然存在。
- 多频道在场。同一个智能体实例通过轻量网关同时接入 Telegram、Discord、Slack 或邮件。
- 闭环学习。操作者的修正直接成为 workspace 的编辑内容,智能体在下一轮对话时即可读取。
NousResearch 提供了完整的参考实现,包含 TUI、多频道网关、技能系统和强化学习训练钩子。在 Claude Code CLI subprocess 之上构建的轻量变体,将活动部件精简到足以按咨询项目模板化复用的程度。以下模式对两种实现均适用。
模式一:类型化空回复处理
基于关键词的触发器(在群消息中匹配 \bhermes\b)可能在包含 Bot 名称但并非直接提问的句子上触发。LLM 正确返回空输出。下游三个环节均未处理空值情况:
- 引擎返回
"",返回码为 0。 - 分割函数因
len("") <= max_chars条件成立,返回[""]。 - 发送循环调用
bot.send_message(chat_id, "");Telegram 返回Bad Request: message text is empty;handler 顶层的通用except Exception吞掉了 traceback,并向用户发送面向用户的报错信息。
仅在单个环节过滤空字符串可以避免崩溃,但会产生静默跳过——触发器已触发,Bot 消耗了算力,用户却看不到任何反馈。两步修复方案:为空输出定义类型化异常,并在触发消息上添加 Telegram 表情反应(👀)作为确认:
class HermesEmptyResponse(HermesError):
"""Subprocess returned successfully but with empty result."""
class HermesHangError(HermesError):
"""Watchdog killed subprocess after no stream-event for N seconds."""
当 result.strip() == "" 时,引擎抛出 HermesEmptyResponse。handler 捕获后调用 message.react([ReactionTypeEmoji(emoji="👀")])。对话日志中写入一条标记块——作为独立条目记录这次静默确认,不向聊天发送文本——这样智能体在下次读取自身记忆时能看到「触发器已触发且被有意忽略」的记录。
模式二:带懒加载缓存的表情反应权限预检
Telegram 的 setMessageReaction 并非在所有场景下都可用。部分群组限制了可用表情集;部分自定义表情需要管理员白名单授权。ChatFullInfo 类型文档说明了规则:若 available_reactions 字段缺失,则所有标准表情均可使用;若为数组,则只有数组中的表情有效。Bot 需要是群成员——对群组中的表情反应无需管理员身份。
每次触发都验证会浪费 API 调用次数。每个 chat 调用一次 getChat,结果缓存一小时即可:
_reaction_cache: dict[int, tuple[bool, float]] = {}
_REACTION_CACHE_TTL_SEC = 3600
MINI_ACK_EMOJI = "👀"
async def _reactions_allowed(bot: Bot, chat_id: int) -> bool:
now = time.monotonic()
cached = _reaction_cache.get(chat_id)
if cached and cached[1] > now:
return cached[0]
try:
chat = await bot.get_chat(chat_id)
allowed = (
chat.available_reactions is None
or any(
isinstance(r, ReactionTypeEmoji) and r.emoji == MINI_ACK_EMOJI
for r in (chat.available_reactions or [])
)
)
except Exception:
allowed = False
_reaction_cache[chat_id] = (allowed, now + _REACTION_CACHE_TTL_SEC)
return allowed
无论如何,实际的表情反应调用都应包裹在 try/except (TelegramBadRequest, TelegramForbiddenError) 中——缓存会滞后于权限变更。
模式三:流式模式与空闲超时 Watchdog
对整个 subprocess 设置硬性超时(asyncio.wait_for(proc.communicate(), timeout=300))可以限制总时长,但不管进度如何都会强制终止。直接移除而不加替代方案是不安全的:Claude Code 流式空闲挂起问题描述了 API 调用在流式传输中途卡死且永不返回的场景,会导致 subprocess 泄漏。
切换到 --output-format stream-json --verbose --include-partial-messages 后,每个关键节点都会发出事件——逐 token 的 text_delta、工具调用开始和结束、API 重试、限流通知,以及最终的 result 事件。真正的卡死在流上表现为静默;长任务则会持续输出一系列小事件。Watchdog 根据空闲时间而非总时长来决定是否终止进程:
WATCHDOG_NO_EVENT_SEC = 60
async def watchdog() -> None:
while True:
await asyncio.sleep(5)
if proc.returncode is not None:
return
idle_sec = time.monotonic() - state["last_event_ts"]
if idle_sec > WATCHDOG_NO_EVENT_SEC:
state["killed_by_watchdog"] = True
try:
proc.kill()
except ProcessLookupError:
pass
return
最终响应文本来自 result 事件的 result 字段——来源单一、确定性强,不受流式解析中间状态影响。同一事件还携带 is_error、api_error_status、duration_ms 和 total_cost_usd,全部记入结构化日志行。
模式四:校准安抚提示计划
阈值问题——Bot 在长时间调用期间何时发送文字进度更新——是经验性的。正确答案取决于真实触发请求的延迟分布。以下三档阈值,文案与用户实际需要了解的信息对齐:
_REASSURE_SCHEDULE = (
(15, "On it."),
(90, "Taking longer than usual, still on it."),
(300, "Genuinely large task — almost there."),
)
这些阈值由两个约束条件推导而来。下限由典型短回复的延迟决定:如果大多数回复在 X 秒内到达,那么第一条安抚信息必须在 X 秒之后触发,否则会与答案几乎同时出现。Nielsen 的响应时间研究将 10 秒确定为在不显示进度指示器的情况下维持用户注意力的典型上限;aiogram 的 ChatActionSender 在该阈值以下渲染的「正在输入」指示器已经能满足约 15 秒以内的需求。
上限阈值(90 秒)是措辞从「正在处理」转变为「处理时间比平时长」的分界点——这是一个独立信号,表明当前调用处于延迟分布的长尾区间。措辞应避免暗示用户提出了复杂请求。Bot 是在做工作;消息确认的是工作本身,而非请求。
模式五:按 Chat 的并发锁
两名群成员可能在同一秒内触发智能体——一人使用 @-mention,另一人使用关键词。两个 handler 调用都会对同一个持久 session 文件启动 claude --continue subprocess。session 锁文件并不严格;并发写入会产生被截断的 session-jsonl 文件和丢失的对话轮次。
在 handler 层通过懒加载创建的锁实现按 chat 串行化:
_chat_locks: dict[int, asyncio.Lock] = {}
def _get_chat_lock(chat_id: int) -> asyncio.Lock:
lock = _chat_locks.get(chat_id)
if lock is None:
lock = asyncio.Lock()
_chat_locks[chat_id] = lock
return lock
async with _get_chat_lock(message.chat.id):
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
懒加载非常重要:在模块导入时实例化的 asyncio.Lock 会绑定到导入时的当前事件循环,而重启后 handler 运行的事件循环可能并非同一个。将实例化推迟到在活跃循环内首次调用时,可以避免这种绑定不匹配。对于小型群组,锁字典保持较小规模;对于更大规模的场景,可以添加 LRU 淘汰策略。
模式六:异常层级与 except 顺序
引擎异常类构成一棵树:
HermesError(RuntimeError)——subprocess 相关的所有错误HermesEmptyResponse(HermesError)——成功运行但结果为空HermesHangError(HermesError)——被 watchdog 终止
Python 的 except 匹配第一个兼容的分支。如果 except HermesError 写在子类 handler 之前,它会捕获 HermesEmptyResponse 并将其路由到报错路径,绕过轻量确认逻辑。子类优先的顺序是必须的:
try:
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
except HermesEmptyResponse:
# 轻量确认路径
...
except HermesHangError as exc:
# 重试一次后放弃的路径
...
except HermesError as exc:
# 退出码非零、API 报错等
...
except Exception:
# 最后兜底
...
将此项加入 Code Review 检查清单。通过视觉扫描对分支重新排序会颠倒原本的意图。
模式七:并行排空 stderr
对 stdout 进行流式读取要求逐行读取到达的内容:async for line in proc.stdout。如果 stderr 也被 pipe 接管,subprocess 可能在 stdout 仍在读取期间填满 stderr 缓冲区。Linux 上默认的 pipe 缓冲区约为 64 KB。一旦 stderr 填满,subprocess 会阻塞等待其排空,而 async-for-line 循环则永远无法继续推进。Watchdog 最终会在空闲期过后终止进程,但结果已经丢失。
从 subprocess 启动之初就并行排空 stderr,然后在 proc.wait() 之后 await 排空任务:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(WORKSPACE),
)
stderr_task = asyncio.create_task(proc.stderr.read())
# ... 对 stdout 进行流式循环 ...
rc = await proc.wait()
try:
stderr_b = await stderr_task
except Exception:
stderr_b = b""
stderr = stderr_b.decode("utf-8", errors="replace").strip()
Claude Code CLI 在 stream-json 模式下产生的 stderr 内容极少,因此这种故障在实践中很罕见。修复只需多加一行代码。
模式八:记忆编辑纪律
Hermes 风格智能体将自己的对话日志作为记忆来读取。写入该日志的 Bug 回退消息,在下次读取时与有意为之的历史行为无法区分。第一直觉是插入修正标记([CORRECTION: the previous entry was a bug]),让下次记忆读取时看到修正。
在编辑之前,先确认 Bug 回退消息确实被记录了。在上述情况中,通用 except Exception 块调用了 message.answer(...) 向用户发送错误,但没有调用 conversation_log.log_outgoing(...)。错误消息到达了 Telegram,却从未写入智能体的记忆文件。因此无需做任何追溯性编辑。
将智能体的 workspace 视为属于智能体自己。在任何涉及编辑其中文件的计划之前,先获取一份最新状态快照——智能体可能在上次读取之后已经重写了自己的 CLAUDE.md 或笔记。Anthropic 的上下文工程指南将持久记忆描述为会话间的工件,而非操作者随意涂写的记事本。领域专属技能与其放在操作者编辑、智能体逐渐不信任的文件中,不如与智能体自行维护的笔记并存,这样更为持久。
操作注意事项
Bind-mount 持久化。只要挂载路径不变,workspace 和 Claude OAuth 凭证的 bind-mount 卷在执行 docker compose up -d --force-recreate 后依然存在。在任何 compose 文件编辑前请先确认这一点。
部署前安全检查。在最近五分钟的日志中 grep 是否存在 claude_subprocess_start 但没有对应 claude_result_event 的条目。如果有正在进行中的 subprocess,重启会强制终止它。等到日志干净再操作。更广泛的故障场景,请参阅我们的灾难恢复文章。
跨项目模式复用。完整技术栈——引擎、handler、对话日志、文件接收——只需修改两个环境变量(项目名称和实例 ID)即可克隆到新项目。Bot token、OAuth 凭证、workspace 和白名单均按项目参数化配置。关于并行运行多个项目专属助手的运营视角,请参阅小团队大规模运营。
表情反应选择。👀 表情包含在 Telegram 默认标准表情集中,在 available_reactions 未设置的群组中均可使用。如果某群组限制了自定义表情子集,缓存会反映这一情况,轻量确认会静默跳过。建议将表情作为每次部署的配置常量,而非写死的字面量。
Hermes-Agent 与轻量自定义构建的对比。NousResearch 的框架包含 TUI、斜杠命令系统、多频道网关、技能中心和强化学习训练集成。轻量 Claude Code CLI 封装层以大约十分之一的活动部件实现了相同的对话形态。两者在群聊用户体验问题上最终殊途同归;本文的模式对两者均适用。
各模式的适用时机
这些模式的紧迫程度不同。按遇到的顺序依次应用:
- 模式一(空回复处理):Bot 加入具有关键词触发检测的群组后立即应用。
- 模式四(安抚提示计划):首次出现短回复与安抚消息几乎同时到达后应用。
- 模式三和七(流式模式、stderr 排空):长时间任务开始出现挂起时应用。
- 模式五(并发锁):日志中首次出现 session 文件截断时应用。
- 模式二、六和八是背景加固项——在生产环境出问题之前,于 Code Review 阶段应用。
请先构建项目专属助手:基础架构指南涵盖 container、OAuth、workspace 和 handler 布局。用扩展指南为小团队配置白名单、群组设置和触发检测。当项目开始收到带外通知时,用 DKIM/DMARC 指南为项目添加邮件频道。待上述模式有所需要时,再回到本文。
tva 为不同咨询项目并行运营着多个项目专属助手。如果你需要构建或调优自己的助手,欢迎联系我们。
相关文章
- 通过 Telegram 构建项目专属 AI 助手——本文调优的基础架构
- 将 Telegram AI 助手从单人扩展到团队——白名单、群组设置、触发检测
- 为项目专属 AI 智能体配置邮箱——同一项目专属模式下的邮件频道
- 为领域专属业务工作流构建 AI 智能体技能——让助手在单一领域发挥价值
- 小团队大规模运营:用精简团队管理数十个项目——并行运营多个助手