プロジェクトとともに成長するHermesスタイル・エージェントのチューニング
メッセージングチャンネル——Telegram、Discord、Slack、あるいはシンゲートウェイ経由のメール——上に構築したプロジェクト特化型AIアシスタントは、シングルユーザーのDM環境とマルチユーザーのグループチャット環境とでは振る舞いが大きく変わります。チームをbotにオンボーディングすると、DM環境のテストでは決して露見しない種類のバグが表面化します。キーワードトリガーに起因する空レスポンスクラッシュ、適切でない進捗通知、並列トリガー間の競合状態、エージェント自身の会話ログにおける記憶のドリフトなどがその代表例です。
本記事では、Claude Code CLIをsubprocessとして使うミニマルなHermesスタイル・アシスタントに適用した8つのチューニングパターンを紹介します。フルのNousResearchフレームワークではなく、CLIをサブプロセスとして動かす構成です。各パターンは、具体的な問題とその修正コードをセットで示しています。パターンはどちらのコードベースにも適用できます。
本記事で解決する問題
- キーワードトリガー発火時にLLMがstdoutを空で返すと、
Unexpected errorという汎用返答が届く - レスポンスレイテンシより低いしきい値設定により、短い返答にも毎回「処理中」テキストが送信される
- midストリームで停止したまま応答を返さないsubprocessが、タイムアウトなしに放置される
- グループの2人が同時にエージェントをトリガーした際に、共有セッションに対して競合状態が発生する
- 64 KBバッファが満杯になったタイミングで発生するサイレントなstderr-pipe deadlock
- エラー時のフォールバックメッセージが会話ログに書き込まれ、エージェントが正当な過去の挙動として読み返す
前提条件
本記事は「TelegramでプロジェクトAIアシスタントを構築する」のアーキテクチャを前提としています。プロジェクトごとに1つのDockerコンテナ、非rootユーザー、永続的なOAuth認証済みClaude Code CLI、aiogramのbot wrapper、workspace・セッション状態ボリュームの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がメンバーとして参加し、
can_react_to_messages: trueが設定されたTelegramグループ
同じプロジェクトのメールチャンネルについては、「DKIM・SPF・DMARCを設定したプロジェクト用メールボックスの構築」を参照してください。
Hermesスタイル・エージェントとは
Hermesスタイルというパターンは、NousResearchのオープンフレームワークに由来します。ステートレスなchatbotとは異なる3つの特性があります。
- 永続的な記憶。エージェントがターン間に読み書きするディスク上のworkspaceにより、コンテキストはcontainerの再起動を超えて保持されます。
- マルチチャンネルプレゼンス。同一のエージェントインスタンスが、シンゲートウェイ経由でTelegram・Discord・Slack・メールで会話します。
- クローズドな学習ループ。オペレーターによる修正がworkspaceの編集として反映され、次のターンでエージェントがそれを読み取ります。
NousResearchはTUI、マルチチャンネルゲートウェイ、スキルシステム、RLトレーニングフックを備えたフルのリファレンス実装を提供しています。Claude Code CLIのsubprocessを使ったミニマル構成は、コンサルティングマンデートごとにテンプレート化できる程度に可動部品を少なく保てます。以下のパターンはどちらのアプローチにも同様に適用できます。
パターン1:空レスポンスの型付き例外ハンドリング
キーワードベースのトリガー(グループメッセージで\bhermes\bにマッチ)は、botの名前を含むが宛先でない文に対して発火することがあります。この場合、LLMは正しく空の出力を返します。しかし下流の3つの層がいずれもこの空ケースを処理できません。
- エンジンがreturncode 0で
""を返す。 - split関数が
[""]を返す(len("") <= max_charsがtrueになるため)。 - 送信ループが
bot.send_message(chat_id, "")を呼び出す。TelegramはBad Request: message text is emptyを返す。ハンドラ最上位の汎用except Exceptionがスタックトレースを握りつぶし、ユーザー向けエラーを送信する。
1つの層で空文字列をフィルタリングすればクラッシュは防げますが、サイレントなスキップになります——トリガーは発火し、計算コストも消費しているのに、ユーザーには何も届きません。2段階の修正では、空出力に対して型付き例外を使い、トリガー元メッセージへの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を送出します。ハンドラはこれをキャッチしてmessage.react([ReactionTypeEmoji(emoji="👀")])を呼び出します。会話ログにはマーカーブロックが記録されます——これはサイレント確認を記録するための独立したエントリであり、テキストでチャットを汚染することなく、エージェント自身が次の記憶読み取り時に「トリガーが発火し意図的に返答しなかった」ことを把握できます。
パターン2:遅延キャッシュによるリアクション権限プリフライト
TelegramのsetMessageReactionは普遍的には利用できません。一部のグループはリアクションセットを制限しており、カスタム絵文字には管理者によるallowlist登録が必要です。ChatFullInfo型にはルールが明記されています。available_reactionsが省略されている場合はすべての標準絵文字が許可され、配列の場合はその絵文字のみ使用できます。botはグループのメンバーである必要がありますが、リアクションに管理者権限は不要です。
トリガーごとに検証するとAPIコールが無駄になります。チャットごとに1回のgetChatと1時間のキャッシュで十分です。
_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)でラップしてください。
パターン3:ストリームモードとアイドル時間watchdog
subprocess全体に対するハードタイムアウト(asyncio.wait_for(proc.communicate(), timeout=300))は、進捗に関わらず合計時間を上限で打ち切ります。代替手段なしにこれを取り除くことは安全でないとドキュメントに記載されています。Claude Code stream-idle-hangの問題では、API呼び出しがmidストリームで停止し、subprocessがリークしたまま返ってこない事例が報告されています。
--output-format stream-json --verbose --include-partial-messagesに切り替えると、マイルストーンごとにイベントが送出されます——トークンごとのtext_delta、ツール使用の開始・終了、APIリトライ、レートリミット通知、最終的なresultイベントなどです。本当の停止はストリーム上の沈黙として現れ、長い処理は小さなイベントの連続として現れます。watchdogは合計時間ではなくアイドル時間で kill します。
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も含まれており、これらはすべて構造化されたログ行に記録します。
パターン4:安心通知スケジュールの調整
閾値の問題——bot はいつ長時間実行中の呼び出しに対してテキスト更新を送信するか——は経験的に決まります。正しい答えは実際のトリガーのレイテンシ分布によって変わります。3段階の閾値と、ユーザーが本当に知りたい情報に合ったテキストを設定します。
_REASSURE_SCHEDULE = (
(15, "On it."),
(90, "Taking longer than usual, still on it."),
(300, "Genuinely large task — almost there."),
)
この閾値は2つの制約から導かれます。下限は典型的な短い返答のレイテンシで決まります。多くの返答がX秒以内に届くなら、最初の安心通知はXより後に発火させる必要があります。そうしないと回答とほぼ同時に届くことになります。Nielsenのレスポンスタイム研究は、進捗表示なしにユーザーの注意を引き続ける上限として10秒を挙げています。aiogramのChatActionSenderがそのタイムフレームで表示するタイピングインジケータは、約15秒までその要件を満たします。
上限の閾値(90秒)は、フレーミングが「作業中」から「通常より時間がかかっているが作業中」に変わるポイントです——分布の長テールにある呼び出しであることを示す独立したシグナルです。文言は、ユーザーが重い処理をリクエストしたという含意を避けています。作業をしているのはbotであり、メッセージはリクエストではなくその作業を認めるものです。
パターン5:チャットごとの並行lock
グループの2人のメンバーが同じ秒に——一方は@メンション、もう一方はキーワードで——エージェントをトリガーできます。両方のハンドラ呼び出しが、同じ永続セッションファイルに対してclaude --continueのsubprocessを起動します。セッションlock-fileは厳密ではなく、並行書き込みによってセッションのjsonlファイルが切り詰められ、ターンが失われます。
ハンドラ層でチャットごとに直列化するには、遅延生成のlockを使います。
_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)
...
遅延生成は重要です。モジュールのimport時に生成したasyncio.Lockは、import時点で有効なevent loopにバインドされます。再起動後にハンドラが動作するloopとは異なる可能性があります。アクティブなloop内での最初の呼び出しまでインスタンス化を遅らせることで、このバインドのミスマッチを回避できます。小規模なグループではlockの辞書は小さいままですが、より大規模な構成ではLRU evictionを追加してください。
パターン6:例外の階層構造とexcept節の順序
エンジンの例外クラスはツリー構造を形成します。
HermesError(RuntimeError)— subprocessに関するあらゆるエラーHermesEmptyResponse(HermesError)— 正常終了だが結果が空HermesHangError(HermesError)— watchdogがkill
Pythonのexceptは最初に一致する節にマッチします。except HermesErrorがサブクラスのハンドラより前にある場合、HermesEmptyResponseを捕捉してエラーパスにルーティングし、mini-ackをスキップしてしまいます。サブクラスを先に書く順序が必要です。
try:
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
except HermesEmptyResponse:
# mini-ack path
...
except HermesHangError as exc:
# retry-once-then-bail path
...
except HermesError as exc:
# exit-not-zero, api-error, etc.
...
except Exception:
# last resort
...
これをコードレビューのチェックリストに追加してください。ブロックを視覚的なスキャンで並び替えると意図が逆になります。
パターン7:stderrの並行ドレイン
stdoutのストリーミングには、到着した行を順次読み取る必要があります(async for line in proc.stdout)。stderrもパイプされている場合、stdoutが読み取られている間にsubprocessがstderrバッファを満杯にすることがあります。Linuxのデフォルトのパイプバッファは約64 KBです。stderrが満杯になると、subprocessはドレインを待ってブロックし、async-for-lineのループが進まなくなります。watchdogが最終的にアイドル時間後にkillしますが、結果は失われます。
subprocessの起動直後からstderrを並行してドレインし、proc.wait()の後でdrainタスクを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())
# ... stream-loop on 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をほとんど出力しないため、この障害モードは実際には稀です。修正は1行追加するだけです。
パターン8:記憶編集の規律
Hermesスタイルのエージェントは、記憶として自身の会話ログを読み返します。そのログに書き込まれたエラー時のフォールバックメッセージは、次の読み取り時に意図的な過去の挙動と区別がつかなくなります。最初の直感は修正マーカー([CORRECTION: the previous entry was a 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-fileを編集する前に必ず確認してください。
デプロイ前の安全チェック。直近5分のログでclaude_subprocess_startに対応するclaude_result_eventがないものをgrepしてください。実行中のsubprocessが残っている場合、再起動で処理中の実行がkillされます。ログがクリーンになるまで待ってください。より広範な障害シナリオについては、disaster recoveryのまとめを参照してください。
マンデートをまたいだパターンの再利用性。フルスタック——エンジン、ハンドラ、会話ログ、ファイルインテーク——は、2つの環境変数(プロジェクト名とインスタンスID)を変更するだけで新しいマンデートにクローンできます。botトークン、OAuthクレデンシャル、workspace、allow-listはプロジェクトごとにパラメータ化されます。多数のプロジェクト別アシスタントを並行して運用する観点については、スケールでのソロオペレーションを参照してください。
リアクション絵文字の選定。👀絵文字はTelegramのデフォルト標準セットに含まれており、available_reactionsが未設定のグループで動作します。グループがカスタムサブセットに制限している場合、キャッシュがそれを反映し、mini-ackはサイレントにスキップします。絵文字はハードコードされたリテラルではなく、デプロイごとの設定定数にしてください。
Hermes-Agentとミニマルカスタムビルド。NousResearchのフレームワークはTUI、スラッシュコマンドシステム、マルチチャンネルゲートウェイ、スキルハブ、RLトレーニング統合を含みます。ミニマルなClaude Code CLI wrapperは、可動部品の約10分の1で同じ会話的な形を実現します。どちらも同じグループチャットUXの問題に行き着きます。本記事のパターンはどちらにも適用できます。
各パターンの適用タイミング
パターンの緊急度は均一ではありません。遭遇した順に適用してください。
- パターン1(空レスポンスのハンドリング)は、botをキーワードトリガー検出付きのグループに追加した直後に必要です。
- パターン4(安心通知スケジュール)は、最初の短い返答が安心通知と同時に届いた後に必要です。
- パターン3と7(ストリームモード、stderrドレイン)は、長時間タスクが停止し始めたら直ちに必要です。
- パターン5(並行lock)は、最初のセッションファイル切り詰めがログに現れたときに必要です。
- パターン2、6、8はバックグラウンドの堅牢化です——本番環境で壊れる前にコードレビュー時に適用してください。
まずプロジェクト特化型アシスタントを構築してください。ベースアーキテクチャガイドでcontainer・OAuth・workspace・ハンドラのレイアウトを説明しています。スケーリングガイドでallow-list・グループ設定・トリガー検出を使って小チームをオンボーディングしてください。帯域外の通知が届き始めたらDKIM/DMARCガイドでプロジェクトのメールチャンネルを追加してください。上記のパターンが必要になったら本記事に戻ってください。
tvaは複数のコンサルティングマンデートに対してプロジェクト別アシスタントを並行して運用しています。自社のアシスタントの構築やチューニングについては、お問い合わせください。
関連インサイト
- TelegramでプロジェクトAIアシスタントを構築する — 本記事がチューニングするベースアーキテクチャ
- Telegram AIアシスタントをソロからチームへスケールする — allow-list・グループ設定・トリガー検出
- プロジェクト別AIエージェント用メールボックスの構築 — 同じプロジェクト別パターンのメールチャンネル
- ドメイン特化型ビジネスワークフロー向けAIエージェントスキルの構築 — アシスタントを特定ドメインで活用する
- スケールでのソロオペレーション:少人数チームで数十のプロジェクトを管理する — 多数のアシスタントを並行して運用する