🧠

「Claude Code」を支える技術

に公開
17
6

本記事では、Claude Code の裏側を支えるアーキテクチャについて、ざっくり理解します。

株式会社ナレッジセンスは、生成AIやRAGを使ったプロダクトを、エンタープライズ企業向けに開発しているスタートアップです。

この記事は何

この記事は、Claude Code の内部構造を、ソースコードレベルで解析した論文[1]について、日本語で簡単にまとめたものです。

https://arxiv.org/abs/2604.14228

本題

ざっくりサマリー

Claude Code は、最も有名な「コーディングエージェント」です。PCのコマンドを実行したり、ファイルを編集したりできます。ただ、この実装の内部のアーキテクチャは公開されていません。

ただ、今回著者らは、2026年4月にリークされたソースコードを解析し、設計の裏側を読み解いた形となります。[2]

この論文で面白かったのは、Claude Code 自体のソースコードのうち、AIが「次に何をすべきか考える」ためのコードは 1.6% ほど。残りの98.4%は、ユーザーにとって気持ちよく・安全に動くようにする 「ハーネス(harness)」 になっているということです。

つまり、Claude Code の賢さの正体は、「至極のプロンプト」とかではなく、「賢いモデルが、思う存分働けるような環境を、ひたすら作り込んでいること」 にあると言えます。

問題意識

「いいAIエージェントを作る」ためのコツは何でしょうか。

かなりの方は、例えば、「いいプロンプトを書く」とか、「AIをガチガチに縛る」ということだと、考えているのではないでしょうか。[3]

しかし、AIをガチガチに縛りすぎると、AIに出来ることが減ります。ユーザーが「色んなことを任せたい!」と考えている状況では、「ガチガチに縛られたAI」は、ユーザーにとって不便です。

そこで、Claude Code は、真逆の戦略で実装されています。つまり、Claude Codeは、AIモデルには大きな裁量を与えたうえで、その周りの「環境」を徹底的に作り込む、という仕組みになっています。

実装の中身

まず、Claude Code の大まかな構造は、非常にシンプルです。「モデルを呼ぶ→ツールを実行する→繰り返す」という、ただのループです。

上の図で重要なポイントは、「AIが考える部分」と「実行許可する部分(ハーネス)」が、別々に分かれていることです。この構造のメリットは、例えばセキュリティです。AIが「やりたい」と言ったことを、「本当に大丈夫か?」と、別の角度からダブルチェックできます。

このシンプルなループの周辺に、AIを制御する「ハーネス」が整理されています。以下、重要な点を5つに分けて見ていきます↓。

① パーミッション

Claude Codeでは、AIが「やりたい」と言ったことでも、別でチェックが実行され、場合によっては却下されます。例えば、AIが「やりたい」と言ったことでも、あらかじめ「NGリスト」にある内容は、100%ブロックされます。(Deny → Ask → Allow の順で処理がなされる)

Anthropic社によると、ユーザーに求められた許可のうち、約93%はそのまま承認されています[4]。つまり、せっかくAIが確認しても、人間はほぼ中身を見ずに OK を押してしまいます。

そこで Claude Codeでは、「そもそも人間が判断する回数を減らす」という工夫がされています。ユーザーに聞く代わりに、Claude がサンドボックスで作業して自己解決する、メタ的なLLMを使ってある程度は自動で危険性を分類する、など。

パーミションシステムひとつを取っても、ユーザー体験と安全性が熟慮されていて、かなり勉強になります。

② 拡張性

Claude Code には拡張の仕組みが4つあります。

  • フック:コンテキスト消費ゼロ
  • スキル:少(説明文のみ)
  • プラグイン:中
  • MCP:多(ツールのスキーマ定義がデカい)

この4つ、なぜ1つに統一しないのか?というと、それぞれ、コンテキストを食う量が違うからです。
というのも、結局、現在のLLMがまともに動くコンテキストの量は、数十万文字です。そのため、あらゆる拡張を全て入れ込むことはできません。

(...まあここは正直、コスパの悪い「MCP」という規格から普及させてしまった、という歴史的経緯もあると思いますが[5]、意図的に複数の拡張手段を残しているのは、コンテキストの節約のためであるとも理解できます。)

③ コンテキスト&メモリ

AIエージェントで一番の制約になるのは、コンテキストウィンドウです。前述の通り、せいぜい数十万文字で何を伝えるか、というのは非常に重要な問題です。


コーディングエージェントでは、LLMに伝えるべきことがたくさんあり、数十万文字あっても全然足りない

そこで、Claude Codeでは、モデルを呼ぶ前に、毎回5段階の圧縮処理がされます。

  • シンプル削減:ツール実行結果などがデカすぎた場合、「ここに大きな出力があった」という参照に置き換える
  • 古い履歴を切り落とす: ある時点以前の会話履歴・ツールCallなどはバッサリ消す
  • microcompact: 詳細不明なのですが、古いtool_resultを消す仕組み
  • context collapse: これも詳細不明ですが、一部の会話ラリーをざっくり要約?に置き換える仕組み
  • auto-compact: ある時点以前の履歴を、全て要約する仕組み。重要な文脈が落ちてしまうリスクが一番大きい

ここでのキモは、いきなり全部を要約せず、まず一番軽い処理から試して、それでもコンテキストが多すぎるなら、徐々に強い処理に切り替えること(lazy degradation)です。

メモリ(CLAUDE.md)の管理について
これは有名な話ですが、Claude Code のメモリシステムでは、ベクトル検索を使っていません。 プレーンテキストの Markdown を階層構造で管理しているだけです。必要なときは、LLM がファイルの見出しをスキャンして関連しそうなファイルを最大5つ選ぶ、というシンプルな方式です。
いわゆる「RAG」は「Scaffolding」と見なされており[6]、ちゃんとやろうとすると色々な整備が大変です。なので、あえて使わないというのがClaudeの強い思想です。

Claude.md
CLAUDE.md の中身は、システムプロンプトではなく「ユーザーメッセージ」として渡されています。つまり、CLAUDE.md に書いた指示は「お願い」であって、確実に守られる保証はない(従う確率は上がるが、確定ではない)。確実に守らせたいルールは、CLAUDE.md ではなく、決定論的なパーミッションルール側で縛る。「ガイダンス」と「強制」を、意図的に分けています。

④ サブエージェント

大きなタスクは、サブエージェントに切り出せます。このとき、サブエージェントは独立したコンテキストで動き、親には最終的な要約だけを返します。つまり、会話の「生の履歴」自体は、特に親には渡されません。これにより、親のコンテキストが節約できます。

ちなみにサブエージェントの分離には Git の worktree が使われていて、コンテナのような重い仕組みを持ち込まず、ファイルレベルの分離が実現されています。

⑤ セッション永続化

会話が発生するたびに append-only の JSONL としてディスクに書き出されます。これにより、後から再開・分岐が簡単にできます。
ただし、再開しても「許可」は復元されないよう、意図的に設計されており、「信頼はセッションごとに establish し直す」という考え方です。

「Claude Code」を300行くらいで実装する

以下、Claude Codeのざっくりの仕組みをPythonで実装してみました。

minimal_claude_code.py 全文
minimal_claude_code.py
#!/usr/bin/env python3
"""
Claude Codeの「ハーネス」を勉強するためのシンプルなコード。
LLM返答をあえてモックし、見やすくしています。

  モックLLM返答 -> 権限判定 -> tool実行 -> tool_result
  -> transcript/context -> 次のモックLLM返答

実行:
  py -3 minimal_claude_code.py
  py -3 minimal_claude_code.py --mode default
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any


def now() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%S%z")


def uid(prefix: str = "") -> str:
    return prefix + uuid.uuid4().hex[:10]


class Transcript:
    """会話・tool実行・権限判定をJSONLで保存。Claude Codeのsession transcriptのミニ版。"""

    def __init__(self, workdir: Path) -> None:
        self.path = workdir / ".mini_agent" / f"{uid()}.jsonl"
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def append(self, event: dict[str, Any]) -> None:
        with self.path.open("a", encoding="utf-8") as f:
            f.write(json.dumps({"time": now(), **event}, ensure_ascii=False) + "\n")


class Context:
    """次のLLM返答に渡す履歴を管理。古い履歴はsummaryに置き換え。"""

    def __init__(self, keep_last: int) -> None:
        self.keep_last = keep_last
        self.events: list[dict[str, Any]] = []

    def add(self, event: dict[str, Any]) -> None:
        self.events.append(event)

    def visible(self) -> list[dict[str, Any]]:
        if len(self.events) <= self.keep_last:
            return list(self.events)
        hidden = len(self.events) - self.keep_last
        return [
            {"type": "compact_summary", "content": f"{hidden} earlier events summarized."},
            *self.events[-self.keep_last :],
        ]


class PermissionGate:
    """deny-firstの権限判定。LLMが申請し、harnessが実行可否を決定。"""

    def __init__(self, workdir: Path, mode: str) -> None:
        self.workdir = workdir.resolve()
        self.mode = mode
        self.danger = {"rm", "rmdir", "del", "erase", "format", "shutdown", "sudo", "curl", "wget"}

    def decide(self, action: dict[str, Any]) -> tuple[str, str]:
        denied = self.hard_deny(action)
        if denied:
            return "deny", denied
        if self.mode == "auto":
            return self.auto_allow(action)
        if self.mode == "dontAsk":
            return "allow", "dontAsk mode, hard denies still apply"
        return self.ask_user(action)

    def hard_deny(self, action: dict[str, Any]) -> str | None:
        if action.get("type") != "tool_use":
            return None
        tool = action.get("tool")
        args = action.get("args", {})
        if tool not in {"list_files", "read_file", "replace_in_file", "run_command"}:
            return f"unknown tool: {tool}"
        if tool in {"read_file", "replace_in_file"}:
            path = (self.workdir / str(args.get("path", ""))).resolve()
            if not self.inside(path):
                return "path is outside workspace"
        if tool == "run_command":
            argv = args.get("argv", [])
            if not isinstance(argv, list) or not argv:
                return "argv must be a non-empty list"
            exe = Path(str(argv[0])).name.lower()
            if exe in self.danger:
                return f"dangerous command denied: {exe}"
        return None

    def auto_allow(self, action: dict[str, Any]) -> tuple[str, str]:
        tool = action["tool"]
        if tool in {"list_files", "read_file", "replace_in_file"}:
            return "allow", "auto: local file action"
        argv = " ".join(str(x).lower() for x in action.get("args", {}).get("argv", []))
        if tool == "run_command" and any(x in argv for x in ["pytest", "unittest", "python", "py"]):
            return "allow", "auto: python/test command"
        return "ask", "auto is unsure"

    def ask_user(self, action: dict[str, Any]) -> tuple[str, str]:
        if not sys.stdin.isatty():
            return "deny", "cannot ask in non-interactive terminal"
        print("\nPermission request:")
        print(json.dumps(action, ensure_ascii=False, indent=2))
        ok = input("Allow? [y/N] ").strip().lower() == "y"
        return ("allow", "approved by user") if ok else ("deny", "rejected by user")

    def inside(self, path: Path) -> bool:
        try:
            path.relative_to(self.workdir)
            return True
        except ValueError:
            return False


class Tools:
    """LLMが直接触れない外界操作。harnessだけがここを呼び出せる。"""

    def __init__(self, workdir: Path) -> None:
        self.workdir = workdir.resolve()

    def run(self, action: dict[str, Any]) -> dict[str, Any]:
        """tool_use actionを具体的なPython関数にdispatchする。"""

        tool = action["tool"]
        args = action.get("args", {})
        if tool == "list_files":
            return self.list_files(args.get("glob", "**/*"))
        if tool == "read_file":
            return self.read_file(args["path"])
        if tool == "replace_in_file":
            return self.replace_in_file(args["path"], args["old"], args["new"])
        if tool == "run_command":
            return self.run_command(args["argv"])
        return {"ok": False, "error": f"tool not implemented: {tool}"}

    def list_files(self, glob: str) -> dict[str, Any]:
        files = []
        for p in self.workdir.glob(glob):
            if p.is_file() and ".mini_agent" not in p.parts:
                files.append(str(p.relative_to(self.workdir)))
        return {"ok": True, "files": sorted(files)[:200]}

    def read_file(self, path: str) -> dict[str, Any]:
        target = self.workdir / path
        text = target.read_text(encoding="utf-8")
        return {"ok": True, "path": path, "content": text[:12000]}

    def replace_in_file(self, path: str, old: str, new: str) -> dict[str, Any]:
        target = self.workdir / path
        text = target.read_text(encoding="utf-8")
        if old not in text:
            return {"ok": False, "error": "old text not found", "path": path}
        target.write_text(text.replace(old, new, 1), encoding="utf-8")
        return {"ok": True, "path": path, "changed": True}

    def run_command(self, argv: list[str]) -> dict[str, Any]:
        proc = subprocess.run(
            [str(x) for x in argv],
            cwd=str(self.workdir),
            text=True,
            capture_output=True,
            timeout=30,
        )
        return {
            "ok": proc.returncode == 0,
            "returncode": proc.returncode,
            "stdout": proc.stdout[-6000:],
            "stderr": proc.stderr[-6000:],
        }


MOCK_LLM_REPLIES = [
    {
        "type": "tool_use",
        "thought": "Inspect the workspace before acting.",
        "tool": "list_files",
        "args": {"glob": "**/*.py"},
        "summary": "",
    },
    {
        "type": "tool_use",
        "thought": "Run the tests to observe the failure.",
        "tool": "run_command",
        "args": {"argv": [sys.executable, "-m", "unittest", "discover", "-v"]},
        "summary": "",
    },
    {
        "type": "tool_use",
        "thought": "Read the implementation file imported by the test.",
        "tool": "read_file",
        "args": {"path": "auth.py"},
        "summary": "",
    },
    {
        "type": "tool_use",
        "thought": "Apply the minimal textual fix visible in auth.py.",
        "tool": "replace_in_file",
        "args": {"path": "auth.py", "old": '"wrong"', "new": '"secret"'},
        "summary": "",
    },
    {
        "type": "tool_use",
        "thought": "Rerun tests after the edit.",
        "tool": "run_command",
        "args": {"argv": [sys.executable, "-m", "unittest", "discover", "-v"]},
        "summary": "",
    },
    {
        "type": "finish",
        "thought": "The observed tests pass.",
        "tool": "none",
        "args": {},
        "summary": "Done. The mocked LLM replies led the harness to fix the demo test.",
    },
]


class MockLLM:
    """構造化されたLLM返答に見えるfixture actionを返す。"""

    def __init__(self) -> None:
        self.i = 0

    def next_action(self, objective: str, context: list[dict[str, Any]]) -> dict[str, Any]:
        """本物のLLMなら、objectiveとcontextを見て次のactionを選ぶ部分。ここではfixtureを順番に返す。"""

        action = dict(MOCK_LLM_REPLIES[min(self.i, len(MOCK_LLM_REPLIES) - 1)])
        action["id"] = uid("act_")
        action["objective_seen"] = objective
        action["context_events_seen"] = len(context)
        self.i += 1
        return action


class Agent:
    """model reasons / harness executes の接続部分。agent loop本体。"""

    def __init__(self, workdir: Path, prompt: str, mode: str, keep_last: int) -> None:
        self.workdir = workdir.resolve()
        self.prompt = prompt
        self.context = Context(keep_last)
        self.transcript = Transcript(self.workdir)
        self.gate = PermissionGate(self.workdir, mode)
        self.tools = Tools(self.workdir)
        self.llm = MockLLM()

    def record(self, event: dict[str, Any]) -> None:
        self.context.add(event)
        self.transcript.append(event)

    def run(self, max_turns: int) -> None:
        """ユーザー入力から始め、LLM返答、権限判定、tool実行、結果記録を繰り返す。"""

        self.record({"type": "user_prompt", "content": self.prompt})
        for turn in range(1, max_turns + 1):
            action = self.llm.next_action(self.prompt, self.context.visible())
            self.record({"type": "llm_reply", "turn": turn, "action": action})
            print(f"\nturn {turn}: {action['type']} {action['tool']} - {action['thought']}")

            if action["type"] == "finish":
                print("\nassistant:", action["summary"])
                return

            decision, reason = self.gate.decide(action)
            self.record({"type": "permission", "action_id": action["id"], "decision": decision, "reason": reason})
            print(f"permission: {decision} ({reason})")

            result = self.tools.run(action) if decision == "allow" else {"ok": False, "error": reason}
            self.record({"type": "tool_result", "action_id": action["id"], "tool": action["tool"], "result": result})
            self.print_result(result)
        print("\nassistant: stopped after max turns")

    def print_result(self, result: dict[str, Any]) -> None:
        print("tool_result:", "ok" if result.get("ok") else f"failed ({result.get('error') or result.get('returncode')})")
        for key in ["files", "stdout", "stderr", "content"]:
            if key in result and result[key]:
                value = result[key]
                text = "\n".join(value) if isinstance(value, list) else str(value)
                print(text[-1000:])


def create_demo_project() -> Path:
    """動作確認用に、意図的にテストが失敗する小さなPythonプロジェクトを作る。"""

    workdir = Path(tempfile.mkdtemp(prefix="mini-agent-")).resolve()
    (workdir / "auth.py").write_text(
        'def login(username, password):\n    return username == "admin" and password == "wrong"\n',
        encoding="utf-8",
    )
    (workdir / "test_auth.py").write_text(
        """import unittest
from auth import login

class AuthTest(unittest.TestCase):
    def test_admin_login(self):
        self.assertTrue(login("admin", "secret"))

    def test_wrong_password(self):
        self.assertFalse(login("admin", "wrong"))

if __name__ == "__main__":
    unittest.main()
""",
        encoding="utf-8",
    )
    return workdir


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--workdir", type=Path, help="Defaults to a temp demo project.")
    parser.add_argument("--prompt", default="Fix the failing Python test. Keep the change minimal.")
    parser.add_argument("--mode", choices=["auto", "dontAsk", "default"], default="auto")
    parser.add_argument("--max-turns", type=int, default=12)
    parser.add_argument("--keep-last", type=int, default=12)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    workdir = args.workdir.resolve() if args.workdir else create_demo_project()
    print(f"workdir: {workdir}")
    agent = Agent(workdir, args.prompt, args.mode, args.keep_last)
    agent.run(args.max_turns)
    print(f"\ntranscript: {agent.transcript.path}")


if __name__ == "__main__":
    main()

今回は、以下のような構造で実装しています。

MockLLM
  LLMから返ってきた想定のJSON actionを返す

Agent
  そのJSON actionを受け取る

PermissionGate
  実行してよいか判定する

Tools
  許可されたactionだけ実行する

Context / Transcript
  user_prompt / llm_reply / permission / tool_result を記録する

.
以上、色々見てきましたが、Claude Codeでは、「モデルが賢くなるほど、差がつくのはハーネスだ」 という強い思想があるようです。

モデルを決まったレールに乗せるより、AIはなるべく何でも出来る環境を作る。ただし、安全に・意図通りのことをやれるようにする、ということが重要だということが理解できます。

まとめ

弊社では普段、エンタープライズ向けにAIエージェントの開発をしています。

大企業では既に、生成AIが普及しています。翻訳・要約みたいな汎用タスクでは、もう誰も困っていないレベルです。

ただ、だからこそ最近は、「AIに任せたい業務」が、かなり多様化してきています。そして、ニーズが多様化している中で、業務特化のAIをチマチマ作っていても、ニーズをカバーしきれません。[7]

そこで Claude Codeでは、汎用的に問題を解けるAIを、ハーネスで制御しながら構築しています。この流れは今後も加速すると考えられます。

実際、我々の会社でも最近、「Cowork」という「完全自律型のAI」をリリースしました。実装はめちゃくちゃ難しいですが、その分、解決できる課題の範囲はグッと広がっています。

みなさまが業務でエージェントを活用する際も、本記事を参考にしていただければ幸いです。今後も、AIの活用精度を上げるような工夫や研究について、記事にしていこうと思います。我々が開発しているサービスはこちら

脚注
  1. "Dive into Claude Code: The Design Space of Today's and Future AI Agent Systems", Liu et al. ↩︎

  2. リークされたものを解説して記事にするのはお行儀が良くないという見解もあるかと思います。また、あくまで第三者による解析であり、公式情報ではない点にご注意ください。 ↩︎

  3. ※実際それも、エージェント開発のメジャーな流派です。いわゆる「Scaffolding(足場)」と呼ばれていて、AIをガチガチに縛ることで、毎回、期待通りの成果が出せるという仕組みです。 ↩︎

  4. https://www.anthropic.com/engineering/claude-code-auto-mode ↩︎

  5. そもそもMCPという規格を提案したのもAnthropic社です(2024年11月)。コスパの良いスキル機能は2025年10月に同じくAnthropicから提唱された規格です。 ↩︎

  6. 例えばこちら https://www.youtube.com/watch?v=julbw1JuAz0&t=3100s ↩︎

  7. 念の為ですが、「業務特化のAI」自体は、狭いドメインの課題を解き切るために、非常に重要です。ここでは、ニーズが爆発している中で、たくさんの課題を解きたい場合、業務特化AIというアプローチは向いていない、という話です。 ↩︎

17
6
ナレッジセンス - 生成AIとRAGの実装戦略・技術ブログ

Discussion

ログインするとコメントできます
17
6