Agent Platform은 manifest-first lazy-load와 skill 프롬프트로 source 검색 시스템을 구현한다
요약
- 거대한 도메인 자료(
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 가 핵심:
wiki-manifest.jsonl을 한 번 Read (한 줄=한 페이지 메타)- title/tags/path 매칭으로 후보 5–10개 추리기 (anti-sampling)
- 같은 턴에서 후보 본문 N개를 병렬 Read (one-shot 원칙, batch 쪼개지 말 것)
- 매칭 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 server —
createSdkMcpServer+tool(name, description, zodSchema, handler). 외부 프로세스 없이 같은 프로세스에서 도구 핸들러 실행. tool 이름은mcp__<server>__<tool>규약 (예:mcp__policy__policy_save). - whitelist + 정책 도구 자동 머지 —
ALLOWED_TOOLSenv 가 명시되면 화이트리스트 모드, 이때POLICY_MCP_TOOL_NAMES를 강제로 합쳐서 정책 도구는 항상 호출 가능. - 세션 resume —
sessionId만 넘기면 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 backpressureallowedTools화이트리스트 — 명시 시 사용 가능한 도구 폭이 줄어듦, agent 의 탐색 공간 자체가 작아짐abortControllerchain — 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.ts—query()호출 / 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줄 manifestsource/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 탐지 트리거가 되는 사례