Zettelkasten

Agent Platform은 manifest-first lazy-load와 skill 프롬프트로 source 검색 시스템을 구현한다

·수정 2026.05.31·수정 2

요약

  • 거대한 도메인 자료(source/)를 LLM 컨텍스트에 통째로 넣지 않고, JSONL manifest + frontmatter skill 인덱스라는 두 단계 메타 인덱스로 추상화한다.
  • 검색 전략은 코드가 아니라 system prompt 안의 절차 명령으로 강제한다 — wiki 먼저 → 0 hit 일 때만 code search, 다축 질문 self-check 등.
  • Claude Agent SDK 의 query() + in-process MCP server 로 정책 CRUD 까지 에이전트 자신이 호출하게 만든다.

본문

문제 정의 — source 자료가 LLM 한 번에 들어갈 사이즈를 넘는다

source/ 는 4개 layer 로 구성된 도메인 지식 저장소다.

경로 규모 성격
wiki/ 670+ md (sources/concepts/entities/topics) 큐레이션된 사내 위키
code/ 5개 레포 클론 검색용 코드베이스
policies/*.md 6개 정책 도메인 의사결정 룰 (SQLite 정식 저장)
skills/*.md 4개 스킬 검색 절차 정의

전부 system prompt 에 박으면 토큰 폭발 + 입력 자체가 모델 한도 초과. 그래서 메타만 컨텍스트에, 본문은 on-demand Read 하는 lazy-load 가 필요하다.

핵심 추상화는 한 다이어그램으로 요약된다:

flowchart LR
    raw["source 본문<br/>(wiki 670+ / code 5 repos /<br/>policies / skills)"]
    raw -->|메타 추출| idx["메타 인덱스<br/>(manifest / skill frontmatter /<br/>INDEX.md)"]
    idx -->|system prompt 합성| ctx["agent 컨텍스트<br/>(lightweight)"]
    ctx -.->|on-demand Read /<br/>policy_get / grep| raw

    classDef heavy fill:#fee,stroke:#c44
    classDef light fill:#efe,stroke:#494
    class raw heavy
    class idx,ctx light

본문(붉은색)은 항상 fs/DB 에 있고, agent 컨텍스트(녹색)에는 메타만. 점선이 "필요할 때만 본문 fetch" 흐름.

1. 검색 전략은 prompt 안의 절차로 박는다

코드가 검색을 수행하지 않는다. agent 가 스스로 Read/Glob/Grep 해서 찾는다. 대신 시스템 프롬프트가 절차를 강하게 규정한다.

source/skills/wiki-search.md 가 핵심:

  1. wiki-manifest.jsonl한 번 Read (한 줄=한 페이지 메타)
  2. title/tags/path 매칭으로 후보 5–10개 추리기 (anti-sampling)
  3. 같은 턴에서 후보 본문 N개를 병렬 Read (one-shot 원칙, batch 쪼개지 말 것)
  4. 매칭 0개일 때만 code search 단계 진입 (허락 필요)

이 절차가 단순한 "well, search the wiki" 가 아니라 안티패턴까지 명시되어 있다 — "manifest Read 후 다음 턴으로 미루기 금지", "Grep 추가 호출 금지 (메타가 컨텍스트에 있으니 직접 필터)", "wiki 본문 그대로 paste 금지". 회귀 사례까지 명시 ("BE-002 가드 동작 누락", "GS-PM-002 부속 페이지 누락").

또한 다축 질문 self-check 절차 — "X와 Y", "그리고", 의문사 2개 이상이 있으면 차원을 bullet 으로 분리하고 각 차원에 wiki 인용이 답변에 포함됐는지 점검하고서야 답변 작성. prompt-engineering 으로 답변 quality gate 를 만든 셈.

2. wiki index 가 커지는 문제 — manifest 메타 인덱스

670+ 페이지 본문을 system prompt 에 못 넣는다. 해법: source/wiki-manifest.jsonl (667줄, 각 줄 한 페이지의 메타).

{"path":"wiki/index.md","title":"위키 인덱스","type":"analysis","tags":["index"],"created":"2026-04-26","updated":"2026-04-30"}
{"path":"connecting-wiki/wiki/concepts/agora-rtt.md","title":"Agora RTT","type":"concept","tags":["rtcall","ai","stt","translation"],"created":"2026-04-26","updated":"2026-04-28"}

본문이 아니라 path/title/type/tags/created/updated 만. 한 페이지 메타가 ~150 토큰 수준이면 670개 ≈ 100K 토큰으로, 이것도 작지 않지만 본문 폭발(M 단위)보다는 LLM 가 다룰 만하다. 그리고 manifest 는 system prompt 에 직접 박지도 않는다 — agent 가 첫 턴에 Read 도구로 가져온다 (CWD 가 source/ 이므로 한 번에 가능).

이 방식의 자연스러운 부수효과: manifest 가 컨텍스트에 한 번 들어오면 같은 세션에선 캐시처럼 동작해서 후속 매칭이 빠르다.

3. skill 인덱스 — 두 번째 lazy-load 층

스킬은 또 다른 lazy-load 패턴이다. SKILLS_PATH 가 가리키는 폴더의 *.md 파일을 서버 부팅 시 읽되, 본문이 아닌 frontmatter 만 추출해서 인덱스로 변환한다 (packages/server/src/config.ts:60-101).

loadSkillsIndex(skillsPath)  // [{ name, description, triggers, path }]
renderSkillsIndex(entries)   // "- **wiki-search** — wiki/... (triggers: ..., path: ...)"

system prompt 에는 한 줄짜리 인덱스만 들어간다:

질의가 아래 스킬의 트리거 와 매칭되면, 검색 시작 전에 해당 path 의 파일을 Read 도구로 먼저 읽어 그 안의 파일 맵 / 패턴 / 함정을 따른다.

agent 가 trigger 키워드(예: "원복", "AB", "PRD") 를 보면 그 시점에 본문을 Read. 본문이 무거워도 (wiki-search.md 만 6KB+) 매번 들어가지 않는다.

4. policies 는 다른 저장 전략 — SQLite + in-process MCP

4개 source layer 가 같은 lazy-load 가 아니라 자료 성격에 맞는 저장소를 골랐다.

Layer 저장소 주입 방식
wiki fs (*.md) manifest 메타 → on-demand Read
skills fs (*.md) frontmatter → 인덱스 line → on-demand Read
code fs (5 repos) grep/glob (skill 절차)
policies SQLite INDEX(메타) + 메타-룰만 always-on, 도메인 룰은 policy_get lazy-load

정책은 DB 가 정식 저장소이고 *.md 는 import source 일 뿐. 정책 수정은 fs Write 가 아니라 in-process MCP 도구 (policy_save) 를 거친다 (policyMcp.ts):

const policySaveTool = tool(
  "policy_save",
  "Idempotent upsert keyed by filename. Use this instead of writing files to disk — policies live in the SQLite DB.",
  { filename, title, type, content, sortOrder },
  async (args) => { upsertPolicy(args); return ...; }
);
export const policyMcpServer = createSdkMcpServer({ name: "policy", tools: [...] });

agent 가 자기 컨텍스트의 정책을 자기가 호출하는 도구로 수정 — fs 와 컨텍스트의 동기화 어긋남이 없다.

주입 방식의 핵심: 정책을 통째로 system prompt 에 박지 않는다. type 필드로 분리:

  • agent (BEHAVIOR.md), index (INDEX.md) — always-on. 모든 답변에 흐름/카탈로그가 필요하니 system prompt 상주.
  • matching / sanction / feature-gating / feed 같은 도메인 룰은 lazy-load. INDEX 가 카탈로그 역할 — agent 가 도메인 키워드 보면 mcp__policy__policy_get('matching.md') 호출해서 그 시점에만 본문 fetch.

즉 wiki manifest, skill index 와 동일한 "메타 → on-demand" 구조를 정책에도 적용. 도메인 정책을 매 query 시스템 프롬프트에 넣으면 토큰 폭증 (6개 합치면 ~25K 토큰) — 메타-룰 (~7K) 만 상주시키고 나머지는 fetch.

5. Claude Agent SDK 사용 — query() async generator + SDK MCP

핵심 호출 (packages/server/src/services/agent.ts:182-204):

const conversation = query({
  prompt: params.prompt,
  options: {
    model, maxTurns, maxBudgetUsd,
    permissionMode: "bypassPermissions",
    allowDangerouslySkipPermissions: true,
    includePartialMessages: true,                       // 토큰 단위 스트리밍
    allowedTools: resolvedTools,                        // policy MCP 자동 합침
    systemPrompt: resolvedSystemPrompt,                 // preset 위에 append
    mcpServers: { policy: policyMcpServer },            // in-process MCP
    cwd: cwdOverride ?? config.CWD,                     // source/ 가 CWD
    abortController,
    pathToClaudeCodeExecutable: process.env.CLAUDE_PATH || DEFAULT_CLI_PATH,
    ...(params.sessionId ? { resume: params.sessionId } : {}),  // 멀티턴 resume
    ...(params.thinking ? { thinking: { type: "adaptive" } } : {}),
  },
});

for await (const message of conversation) {
  emitEvents(message, onEvent);   // SDK message → SSE event 변환
}

핵심 패턴:

  • systemPrompt 합성{ type: "preset", preset: "claude_code", append: "..." } 로 Claude Code 의 기본 도구 프롬프트(Read/Write/Bash/Glob/Grep 사용법) 위에 도메인 정책 + skill 인덱스를 append. 도구 사용 능력은 SDK 가 주고, 도메인 지식만 우리가 얹는다.
  • in-process MCP servercreateSdkMcpServer + tool(name, description, zodSchema, handler). 외부 프로세스 없이 같은 프로세스에서 도구 핸들러 실행. tool 이름은 mcp__<server>__<tool> 규약 (예: mcp__policy__policy_save).
  • whitelist + 정책 도구 자동 머지ALLOWED_TOOLS env 가 명시되면 화이트리스트 모드, 이때 POLICY_MCP_TOOL_NAMES 를 강제로 합쳐서 정책 도구는 항상 호출 가능.
  • 세션 resumesessionId 만 넘기면 SDK 가 이전 컨텍스트 복원, 멀티턴 대화 가능. 우리는 메시지 본문만 SQLite 에 따로 저장 (UI 표시용).
  • abort chain — SSE 클라이언트 연결 끊기면 abortController.abort() → SDK 가 별도 프로세스(Claude Code CLI) 까지 정리. 이게 없으면 query 1개당 ~1GiB RAM 짜리 좀비 프로세스 누적.
  • stream 변환 — SDK 메시지 타입 (system/stream_event/assistant/result) 을 SSE 이벤트 (init/partial/assistant/tool_use/result) 로 1:N 매핑. partial 은 토큰, assistant 는 완성 메시지, tool_use 는 도구 호출. (agent.ts:76-152)

전체 lifecycle 시퀀스:

sequenceDiagram
    autonumber
    participant U as User (web)
    participant S as Server<br/>(Fastify)
    participant Q as SDK query()
    participant T as Tools<br/>(Read/Grep/MCP)
    participant DB as SQLite

    U->>S: POST /api/query (prompt, sessionId?)
    S->>DB: listPoliciesFull()<br/>(meta-rules only)
    S->>S: resolveSystemPrompt<br/>(BEHAVIOR + INDEX + skill index)
    S->>Q: query(prompt, opts)<br/>resume?, mcpServers
    Q-->>S: SSE: init {sessionId}

    Q->>T: Read wiki-manifest.jsonl
    T-->>Q: 667줄 메타
    Q->>T: 후보 N개 병렬 Read (one-shot)
    Q-->>S: SSE: tool_use / partial
    Q-->>S: SSE: assistant (텍스트)

    alt 매칭 0개 — code search 필요
        Q-->>U: 허락 요청 (텍스트)
        U->>Q: (다음 turn) 승인
        Q->>T: Grep code/ + Read
    end

    Q-->>S: SSE: result {cost, turns, usage}
    S-->>U: SSE close

(2)–(3) 이 매 query 마다의 system prompt 합성, (6)–(7) 이 manifest 한 번 읽기 + 병렬 Read 패턴, alt 블록이 wiki→code 단계 분리.

6. 매번 무거운 질문이 안 도록 만드는 전략

검색 시스템의 본질적 위험: 매 query 마다 manifest 풀스캔 + 본문 N개 Read + Grep + code search 까지 하면 token 비용 + 지연 + 별도 프로세스 RAM 모두 폭발한다 (한 query ≈ 1GiB SDK 자식 프로세스). 이걸 막는 layered 가드:

(a) 메타 → 본문 2단 lazy-load (구조적 가드)

  • wiki: 670 페이지 본문 → manifest 한 줄씩의 메타로 압축
  • skills: 본문 6KB+ → frontmatter 한 줄 인덱스
  • policies: 도메인 룰 본문 → INDEX 카탈로그 + policy_get 호출
  • 효과: agent 컨텍스트에 들어가는 건 "어디에 뭐가 있는지" 만. 본문은 truly need-to-fetch 일 때만.

(b) prompt-engineered 검색 절차 (런타임 가드)

  • anti-sampling: 후보 매칭 ≤ 5개면 전부, 초과면 5–10개. "전부 다 일단 Read" 금지.
  • one-shot parallel Read: 후보 N개를 같은 턴에 병렬로 호출. "한 개 보고 부족하면 또 호출" 금지 — turn round-trip 자체가 비싸다.
  • wiki → code 단계 분리: wiki 매칭 0개일 때만 code search 진입. code search 는 가장 비싼 단계 (grep 5 repos + 본문 Read 다수) 라 유저 허락까지 받고 시작.
  • 다축 질문 self-check: "X와 Y" 같은 다축이면 각 차원에 대해 wiki 인용을 답에 포함했는지 체크하고 답변. 한 번에 끝나지 않으면 재진입 비용 발생.
  • 회귀 사례를 prompt 에 명시: 실측 실패 ID (BE-002, GS-PM-002 등) 를 skill 본문에 박아 같은 함정 재발 방지.

(c) SDK 레벨 하드 가드 (안전망)

  • maxTurns (기본 10) — 무한 루프 차단
  • maxBudgetUsd (기본 $1) — 비용 상한 (SDK 가 누적 cost 추적, 초과 시 abort)
  • MAX_CONCURRENT_QUERIES (기본 2) — 한 서버에서 동시 query 제한, 503 backpressure
  • allowedTools 화이트리스트 — 명시 시 사용 가능한 도구 폭이 줄어듦, agent 의 탐색 공간 자체가 작아짐
  • abortController chain — SSE 끊기면 SDK 자식 프로세스까지 정리

(d) layered 효과

가드 layer 막는 것
(a) 구조 본문이 컨텍스트에 들어가서 매 turn 토큰 비용을 갉아먹는 것
(b) 절차 agent 가 "일단 다 검색" 같은 lazy 패턴으로 도구를 남발하는 것
(c) SDK (a)(b) 가 뚫렸을 때 비용/시간/RAM 무한 증가

세 layer 가 다 있어야 안정적. (a) 만 있으면 agent 가 manifest 보고도 모든 본문 다 Read 할 수 있고, (b) 만 있으면 agent 가 절차를 안 따를 때 가드가 없고, (c) 만 있으면 매번 최대치까지 가서 비싸다.

flowchart TB
    Q["query 1회"] --> A
    subgraph A["(a) 구조 가드"]
        A1["메타 → 본문 lazy-load<br/>본문이 컨텍스트에 못 들어감"]
    end
    A --> B
    subgraph B["(b) 절차 가드"]
        B1["anti-sampling (5–10)<br/>parallel Read (1 turn)<br/>wiki→code 단계 분리<br/>다축 self-check"]
    end
    B --> C
    subgraph C["(c) SDK 가드 (안전망)"]
        C1["maxTurns / maxBudgetUsd<br/>MAX_CONCURRENT_QUERIES<br/>allowedTools / abortController"]
    end
    C --> R["응답 (비용·시간 bounded)"]

    classDef stage fill:#f7f7f7,stroke:#666
    class A,B,C stage

각 layer 가 직렬로 쌓이는 구조 — 위쪽이 뚫리면 아래쪽이 받아낸다. (a) 는 사실상 비용 0 (구조), (b) 는 prompt 토큰 비용, (c) 는 가장 비싼 단계지만 최후 안전망.

추상화 한 줄로

거대 source 를 LLM 검색 시스템으로 만들 때는, 자료를 통째로 컨텍스트에 넣지 말고 (1) 자료 성격별 저장소를 고르고 (2) 메타 인덱스를 lazy-load 하게 만들고 (3) 검색 절차를 prompt 안의 안티패턴-포함 명령으로 강제하고 (4) SDK 레벨 하드 가드 (maxTurns / maxBudgetUsd / allowedTools / abort chain) 로 안전망을 둬라. SDK 는 도구 사용 능력만 주고, 도메인은 system prompt + in-process MCP 로 얹는다.

참고

  • packages/server/src/services/agent.tsquery() 호출 / system prompt 합성 / SSE 변환
  • packages/server/src/services/policyMcp.ts — in-process MCP server (createSdkMcpServer + tool)
  • packages/server/src/config.ts:60-101 — skill frontmatter → 인덱스 변환
  • source/wiki-manifest.jsonl — 667줄 manifest
  • source/skills/wiki-search.md — manifest-first 검색 절차 + 안티패턴
  • source/policies/BEHAVIOR.md — agent 동작 규약 (§1.2 wiki path / §1.3 code search)
  • Meridian 경유 Hermes의 out of extra usage 400은 system prompt 특정 텍스트가 트리거다 — 여기서 append하는 도메인 system prompt가 third-party 탐지 트리거가 되는 사례