スクラッチからCLI AIコーディングアシスタントを作った — 学んだこと
TL;DR: 私はClaude Codeのアーキテクチャを数か月かけて調べ、その後Seed AIを構築しました。TypeScriptのCLIアシスタントで、14個のオリジナル改善を加えています。この記事は、私が解決した中でも特に面白かった技術的課題の頭の中のメモ(ブレインダンプ)です。
なぜそんなことを?
Claude Codeはとても優秀です。しかし、いくつかの厳しい制約があります:
- AnthropicのAPIに固定(DeepSeekなし、ローカルのOllamaなし)
- セッション間でのメモリがない
- ツール結果のキャッシュがない(セッション内で同じファイルを3回読み込む)
- Dockerサンドボックスがない
これらはいずれも不満ではありません。プロダクト上の意思決定です。ですが、その分、探る余地が残りました。
面白かった課題
1. UXを壊さずにツールを並列実行する
Claude Codeはツールを直列に実行します:permission(A) → exec(A) → permission(B) → exec(B)。
LLMが同時に3つのファイル読み込みを要求すると、遅延は3倍になります。
雑な解決策は完全な並列化ですが、その場合、3つの許可ダイアログが同時に飛び出してきて、混乱します。正しい分割はこうです:
// 権限:直列(ユーザーが1つずつレビューする)
const approvedCalls: ToolCall[] = [];
for (const call of toolCalls) {
const approved = await askPermission(call);
if (approved) approvedCalls.push(call);
}
// 実行:並列(UX上の対話は不要)
const results = await Promise.allSettled(
approvedCalls.map(call => tools.execute(call.name, call.input))
);
結果:遅延がN×Tから、ほぼ1.2×T(同規模の読み込みN件の場合)まで下がります。
2. セッション単位のツールキャッシュと「書き込み前に無効化」順序
LLMはファイルを頻繁に読み直します。file_read、glob、grepはいずれも冪等です。明らかな最適化は、セッション内でキャッシュすることです。
難しいのは、書き込みに対する無効化の順序です。例えばこうすると:
file_edit(foo.ts) → invalidate cache → read foo.ts (新しい内容を取得) ✓
しかし、無効化が実行の後で起きるなら:
file_edit(foo.ts) → read foo.ts (キャッシュヒット、古い内容を取得) → invalidate ✗
修正は:cache.invalidateForWrite()をexecute()の後ではなく、実行前に行うことです。後から考えれば明らかですが、間違えるとコストが高いです。
キャッシュキーの形式は"file_read:{"path":"/foo/bar.ts"}"です。ツール名に加えて、JSON.stringify(input)を組み合わせます。
典型的なセッションキャッシュヒット率は20〜40%です。
3. LLMによるコンテキスト圧縮
会話が長くなるとClaude Codeは切り詰めます。古いメッセージは消えてしまいます。
より良いアプローチは、利用可能な最も安価なモデル(Haiku、約$0.0002/コール)で、削られた内容を要約し、その要約をシステムプロンプトに注入することです:
const SUMMARY_MODEL = "claude-haiku-4-5-20251001";
// 注入先:システムプロンプトの動的セクション:
// ## 以前の会話の要約(圧縮済み)
// [Completed: refactored auth module, fixed token expiry bug]
// [Decided: use refresh tokens over session extension]
// [Current state: tests passing, ready for PR]
累積する要約は、複数回の圧縮ラウンドにわたって---区切りで追記されます。メインモデルは常に、先に何が起きたかを把握できます。
圧縮あたりのコスト:約$0.0002。無視できるほど小さいです。
4. セッション間のセマンティック・ベクトルメモリ
長期メモリの仕組みは3層構造です:
~/.seed/memory/
├── user.md ← プロジェクト横断のユーザープロファイル
└── projects/{sha1(path)[:12]}/
├── context.md ← 技術スタック、アーキテクチャ
├── decisions.md ← 重要な意思決定+推論
└── learnings.md ← 修正したバグ、うまくいったパターン
プロジェクトのフィンガープリント(sha1(normalize(path)).slice(0, 12))はパスを一貫した形にマップします。つまり、同じプロジェクトなら、パスの書き方がどうであっても同じメモリバケットになります。
セッション終了時に、Haikuは耐久性のある知識だけを抽出します(エフェメラルな変数名などは除外)。セッション開始時には、ローカルの埋め込みモデルがコサイン類似度により関連上位8チャンクを取得します。一定の注入コストは約800トークンで、メモリ全体のサイズに関係ありません。
埋め込みサービスが使えない場合はTF-IDFでフォールバックします。
5. Dockerサンドボックスと、ホストへのフォールバック(円滑な切替)
各bashツール呼び出しは、新しいコンテナを立ち上げ、実行後に自動で削除します:
[
"run", "--rm",
"-v", `${mountPath}:/workspace`,
"-w", "/workspace",
"--network", "none", // strict mode
"--memory", "512m",
"--cpus", "1",
"--security-opt", "no-new-privileges",
"alpine:latest", "sh", "-c", command
]
3つの隔離レベル:strict(読み取り専用FS + ネットワークなし)、standard、permissive。
重要なのは:Dockerが動いていないときにクラッシュしないことです。docker infoは初回利用時にプローブを行い、結果をキャッシュし、システムはホスト上での実行にフォールバックしつつ、目に見える警告を表示します。ユーザーは常に自分がどのモードで動いているかを把握できます。
6. ローカルLLM対応:機能検出
Ollamaは「このモデルはツール呼び出しをサポートするか?」に対する標準的なエンドポイントを公開していません。私が辿り着いたプローブは以下です:
// 最小限のツール利用リクエストを送る
const res = await fetch(`${base}/v1/chat/completions`, {
body: JSON.stringify({ model, messages: [...], tools: [PROBE_TOOL] })
});
if (!res.ok && res.status === 400) {
const body = await res.text();
// 能力のないモデルには「ツールをサポートしていません」を返す
if (/not support tools/i.test(body)) return false;
}
return res.ok;
モデルがネイティブなツール呼び出しをサポートしていない場合は、プロンプト内でXML形式のツール呼び出しにフォールバックします:
<tool_call>
{"name": "file_read", "parameters": {"path": "src/index.ts"}}
</tool_call>
同じToolRegistryが、どちらの経路も扱います。qwen2.5-coderのようなモデルはネイティブ呼び出しをサポートします。DeepSeek-R1のような推論モデルはXMLへのフォールバックが必要です(それでも、まれにしかツール呼び出しを確実に出力しません)。
7. ストリーミングのジッター:React の setState 順序バグ
これは、完全に原因を特定するのに何回かのセッションを要しました。
Ink(CLI向けのReact)は、状態の更新のたびにターミナルを再描画します。ストリーミング中は、再描画をまとめるために80msごとにテキストのデルタをバッファしています。しかし、ストリーム終了時にReactのバッチ順序に関する微妙なバグがありました:
// 壊れていたシーケンス:
// イベント: "done" → finalizeLastMessage() → setMessages({isStreaming: false})
// 最後のブロック → updateLastAssistantMessage(remainingBuffer) → setMessages({isStreaming: true})
// React は両方をバッチする → 後から来た更新が勝つ → メッセージが permanently stuck(ストリーミングのまま固定)
修正:バッファのドレインとisStreaming: falseを、単一の原子的なsetMessages呼び出しに統合します:
} finally {
const remainingChunk = deltaBufferRef.current;
deltaBufferRef.current = "";
setMessages((prev) => {
const copy = [...prev];
const last = copy[copy.length - 1];
// チャンクを追記し、isStreaming: false を1つの updater で設定する
copy[copy.length - 1] = { ...last, content, isStreaming: false };
return copy;
});
}
別件:Ink の <Static> コンポーネントは、完了したメッセージをターミナルのスクロールバックに1回だけ書き込み、再描画はしません。生きたストリーミング領域(30行のターミナルで約22行)のみが、80msごとに再ペイントされます。これが、ターミナルUIでストリーミングのジッターを解消する方法です。
8. MCP サブプロセスのリーク
MCP(Model Context Protocol)サーバーは、stdio を介して通信する永続的な子プロセスとして動作します。最初のバージョンでは、ユーザーの送信のたびに新しい MCPRegistry を作成していました。これにより新しいサーバープロセスが起動され、古いプロセスがオーファンになって残ってしまいました。
直し方は簡単です。ポイントは、ref を使って submit 間でレジストリのインスタンスを保持することです:
const mcpRegistryRef = useRef<MCPRegistry | null>(null);
// 1回だけ接続して、ずっと再利用
if (!mcpConnectedRef.current) {
mcpConnectedRef.current = true;
const reg = new MCPRegistry();
await reg.connect(settings.mcpServers);
mcpRegistryRef.current = reg;
}
症状は微妙でした。クラッシュもエラーもない。ただ、背景でゾンビプロセスが徐々に増えていくだけ。永続的な子プロセスを管理するどんなシステムでも、確認しておく価値があります。
9. Ollama の <think> タグ形式の分岐
DeepSeek-R1 は思考トークンを生成します。呼び出し元によって、2つの異なる形式になります:
DeepSeek のクラウド API: thinking の内容は別の reasoning_content フィールドで届きます。クリーンで明確です。
Ollama の OpenAI 互換エンドポイント: 開始タグ <think> は取り除きますが、終了タグ </think> は保持します。思考の内容は、通常の content ストリームの先頭に表示され、
</think>
で終わります。その後に実際の応答が続きます。
対応方法:
// DeepSeek クラウド:専用フィールド
if (chunk.choices[0].delta.reasoning_content) {
onEvent({ type: "thinking_delta", delta: chunk.choices[0].delta.reasoning_content });
}
// Ollama:content ストリーム内で </think> の閉じタグだけを検出
if (rawContent.includes("</think>")) {
const [thinking, response] = rawContent.split("</think>
");
onEvent({ type: "thinking_delta", delta: thinking });
onEvent({ type: "text_delta", delta: response });
}
ベンダーが「互換」APIを実装するとき、この種の形式の分岐はよくあります。基本ケースでは動くくらいに互換だが、エッジケースでは壊れるほど違う、という状況です。
私ならこうする
最初からスコープを絞る。 リポジトリには参考用としていくつかの CC のソースファイルが含まれていましたが、それが git で追跡されることになってしまいました。1日目から src/seed/ と src/vendor/ をはっきり分けていれば防げたはずです。
機能の前に統合テストを書く。 ユニットテストは、ツールキャッシュと権限システムの退行を検知してくれました。しかしエージェントループのエンドツーエンドには統合テストがありません。ここが、現時点で最大の品質ギャップです。
ターミナルと戦わない。 Ink の <Static> + プライマリバッファが正しいアーキテクチャだと気づく前に、カスタムのスクロールバーや alt-screen バッファに時間を使ってしまいました。ターミナルはそもそも、スクロールする方法をすでに知っています。
リポジトリ
GitHub: https://github.com/jiayu6954-sudo/seed-ai
TypeScript、MIT ライセンス。Node.js 20+ で動作します。Anthropic、OpenAI、DeepSeek、Groq、Gemini、Ollama、OpenRouter、Moonshot をサポートします。
もし同様のものを作っているなら、または上記の内容について質問があるなら、イシューを作成するかコメントしてください。
返却形式: {"translated": "翻訳されたHTML"}



