LLM 을 순수 함수로 두고 tool 실행권을 서버가 소유하는 구조
LLM 을 순수 함수로 두고 tool 실행권을 서버가 소유하는 구조
회사 AX 채팅(자연어로 회의실 예약 같은 업무를 시작하는 비서 표면)을 만들며 관찰한 tool calling
설계 기록이다. 요지는 한 문장이다 — 모델에게 도구 실행권이 없다. 모델은 "이 도구를 이
인자로 실행하고 싶다"는 JSON 을 뱉을 뿐이고, 그걸 화이트리스트에서 찾아 실행하고 결과를 다음
턴 프롬프트에 되먹이는 루프는 전부 서버 코드다. LLM 은 call(purpose, prompt) → str 시그니처의
순수 함수로 격리된다.
층이 넷이다
드라이버 LLM 호출 그 자체 — 프롬프트 in, 텍스트 out. 격리·계측 소유
루프 매 턴 {"tool_calls":[...]} | {"final":...} 파싱 → 실행 → 되먹임
실행기 tool 하나의 집행 — 접근·승인 게이트·바인딩·스키마 4단 검문 + 전량 audit
표면(채팅) 카탈로그 조립 · 컨텍스트 주입 · taint 판정 · 2차 LLM 감사
각 층이 아래층만 알고, 도메인(어떤 프롬프트·어떤 도구 목록)은 전부 위에서 주입된다. 루프·드라이버 코드는 도메인을 import 하지 않는다.
드라이버 — "추론 엔드포인트가 아니라 에이전트다"
서버가 CLI 형 에이전트를 자식 프로세스로 스폰해 LLM 을 호출하는데, 여기서 배운 게 가장 많았다. 아무 제한 없이 스폰하면 CLI 가 스스로 도구를 호출한다 — 발화만 넘긴 호출이 서버 계정 환경의 스킬을 실제로 실행한 실측이 있었다. 그래서 격리가 2겹이다.
- 자식 환경변수는 허용 목록. 원래는 이름에 SECRET/TOKEN 이 들어가면 거르는 블랙리스트였는데, 이름을 맞게 지은 secret 만 잡는다 — 실측에서 클라우드 개인키 원문이 담긴 env 가 마커 하나도 안 걸리고 통과했다. 허용 목록의 대가는 새 env 를 자식이 못 보는 것인데, 그건 조용한 유출이 아니라 CLI 실패로 드러나므로 반대 방향보다 낫다.
- 도구 등록 자체를 0 으로. 도구 이름을 열거해 막는 방식(disallow 목록)은 경계가 아니었다 — 실측에서 20종이 남았고 지연 등록 도구를 다시 불러올 수 있었다. 빈 tools 의 세션 에이전트를 지정해 등록을 0 으로 만든다. 우리 계약이 "모델은 카탈로그에서 고르고 실행은 서버가 소유한다"이므로, CLI 자체 도구는 하나도 떠 있으면 안 된다.
모든 호출은 사용량 원장에 계측된다(모델·용도·토큰·비용·지연). 단 프롬프트·응답 원문은 저장하지 않는다 — 그래서 파싱이 깨진 순간이 원문을 볼 수 있는 유일한 지점이고, 파싱 실패 예외에 원문 앞부분 500자를 실어 재현 안 되는 라이브 실패의 단서를 남긴다.
루프 — 매 턴 JSON 한 덩어리, 나머지는 전부 서버
루프 한 턴의 계약: 모델은 정확히 둘 중 하나를 낸다.
{"tool_calls": [{"name": "<카탈로그 이름>", "arguments": {...}}]} // 실행 제안 → 루프 계속
{"final": {...}} // 산출 → 루프 종료
for _turn in range(max_turns):
prompt = build_prompt(executed, turns_remaining) # 실행 이력을 프롬프트에 되먹임
output = await call(purpose, prompt) # LLM = 순수 함수
turn = TriageTurn.model_validate(parse_or_explain(purpose, output))
if bool(turn.tool_calls) == bool(turn.final is not None):
raise ProviderError("exactly one of tool_calls or final") # XOR 강제
if turn.final is not None:
return turn.final
for tool_call in turn.tool_calls:
fn = tools.get(name) # 화이트리스트 dict — 이게 실행권의 전부
if fn is None:
record_tool_denied(...); executed.append({"ok": False, "error": "unregistered_tool"})
continue # 죽이지 않고 루프 지속
try:
executed.append({"name": name, "ok": True, "result": await fn(arguments)})
except Exception as error: # 툴 실패는 되먹여 대안을 찾게 한다
executed.append({"name": name, "ok": False, "error": str(error)})
raise ProviderError(f"exceeded max turns") # 삼킴 금지
상태가 없다. 대화 메모리도 없다 — 매 턴 프롬프트를 처음부터 다시 조립하고, 지금까지 실행된
tool 의 결과(executed)를 통째로 싣는다. 그래서 루프를 백그라운드로 넘길 때도 클로저나 세션을
캡처하지 않고 식별자(run_id, kind)만 넘겨 fresh session 에서 전부 재조립할 수 있다.
프롬프트 되먹임에서 얻은 실측 교훈 둘:
- 모델이 "이미 실행된 결과"를 "아직 기다리는 중"으로 착각한다. 결과를 JSON 한 덩어리로
덤프하면 산문("Still waiting on ...")으로 이탈해 파서가 죽었다. 그래서 실행 완료 목록을
COMPLETED TOOL CALLS — the results below are ALREADY PROVIDED라벨 블록으로 렌더하고, 매 턴 말미에 "정확히 JSON 오브젝트 하나만, 산문·상태보고 금지" 규율 문장을 박는다. - 프롬프트가 비대해진다. 문서 읽기 tool 결과는 직전 것만 전문 유지하고 이전 것들은
{path, 발췌, already_read}로 축소한다 — "이미 읽은 문서" 목록화.
실패의 최종 처방은 도메인이 주입한 fallback 산출이다. 루프가 max_turns 초과·비JSON 으로 죽으면 조용히 삼키는 게 아니라 degrade 산출(실패 표식 포함)로 진행한다 — "AI 는 하드 게이트가 아니다. AI 가 실패해도 결재 카드는 반드시 태어난다"는 상위 원칙의 구현이다.
실행기 — tool 계약은 데이터 + 포인터, 검문은 4단
tool 정의 자체가 엔진의 ActionDefinition 과 같은 패턴이다 — 정책은 데이터, 행위는 포인터 하나.
@dataclass(frozen=True)
class RuntimeTool:
id, description, mode("read"|"write"), approval_policy
input_model: type[BaseModel]; output_type
execute: Callable # 🟦 유일한 코드 포인터 — 실행기는 per-tool 분기 0
access: ToolAccess # 🟩 기본값 없음 — 빠뜨리면 tool 을 만들 수 없다
model_result / render_result # 모델용 투영 / 사람용 원문 (sealed 2축)
emits_untrusted_text: bool # 제3자 자유 텍스트 방출 자기신고
ToolAccess 에 기본값을 안 둔 이유가 좋았다 — 빠뜨리면 "전원 허용"으로 열리므로, 정말 공개면
ToolAccess.public() 을 명시하게 강제한다. 잊은 것과 공개하기로 한 것이 코드에서 구분된다.
실행기 검문 순서는 고정이고, 거부도 전부 audit 에 남는다(침묵 실패 금지):
- 접근 — 요청자 role/조직 capability 가 tool 선언을 충족하나. 카탈로그 노출 필터와 같은 판정 함수를 쓴다 — 노출은 UX 축소이고 여기가 집행이라, 둘이 갈라지면 "감춰 놨는데 실행은 되는" 상태가 된다.
- 게이트 — write tool 은 승인된 결재(Action) 바인딩 없이는 거부. 사람이 승인한 뒤에만 외부 write 가 실행된다는 계약을 실행기가 최종 강제한다.
- 바인딩 — 그 승인이 지금 유효한 실행 원장에 못박혀 있나.
- 스키마 — 입력 pydantic 검증 → execute → 출력 검증.
접근 제어(누가 이 tool 을 쓰나)와 실행 승인(이 write 가 승인됐나)이 별개 축이라는 게 핵심이다. REST 가 권한으로 막은 데이터를 채팅이 우회하지 못하는 것도, 모델이 어떤 JSON 을 만들어 내든 외부 write 에 도달할 수 없는 것도 이 실행기 한 곳에서 강제된다.
sealed presentation — 모델 경로와 사람 경로를 갈라 담는다
조회 tool 결과에 섞이는 제3자 자유 텍스트(남이 쓴 예약 제목·이름·메모)는 프롬프트 인젝션의
통로다. 그래서 결과를 봉투로 갈라 담는다 — model(모델용 안전 receipt) / render(사람에게 직송할
원문) / untrusted(자기신고). 원문이 모델 슬롯에 들어가는 경로는 dataclass 생성 가드로 구조적으로
막았다: 사람용 직송을 선언한 tool 은 모델용 투영이 반드시 있어야 하고, untrusted 신고도 강제된다.
"모르는 출처를 안전하다고 가정하지 않는다."
AX 채팅 표면 — 한 턴이 할 수 있는 것은 둘뿐
채팅은 특정 워크플로의 기능이 아니라 워크플로 진입 표면이다. 턴 루프는 위의 generic 루프와 같은 모양인데 계약이 더 좁다.
- 한 턴 = read tool 실행 + 워크플로 시작(승인 카드 생성) 둘뿐. 외부 write 는 어떤 턴에서도 직접 일어나지 않는다 — 최대치가 카드이고, 실행은 사람이 승인한 뒤 서버가 한다. 프롬프트에도 같은 문장이 있다: "카드를 만든 뒤 예약이 됐다고 주장하지 마라."
- 고를 수 있는 것은 카탈로그에 있는 것뿐. 고정 의도 분류(intent classifier) 경로를 따로 두지 않고 카탈로그 선택 단일 경로로 통일했다. 카탈로그 밖 이름은 조용한 추측 실행 없이 실패로 표면화된다.
- 대상 지목은 서버가 실어 준 목록에서만. "그거 취소해줘"의 "그거"는 이번 턴 프롬프트에 실린 본인의 대기 카드/최근 완료 목록에서만 고를 수 있다 — 모델이 만든 식별자를 그대로 받으면 착각·유도가 남의 데이터를 대상으로 잡는다.
- 요청자 정보는 이름·이메일뿐, role 은 넣지 않는다. 모델이 자기 권한을 알면 "나는 관리자니까 해줘" 류 유도의 재료가 된다. 권한 집행은 어차피 실행기가 한다.
- 턴 스키마의 표현 차이는 검증기가 흡수한다.
{"tool_calls":[]}·{}·{"final":""}는 전부 "더 할 일 없음"인데, 표현을 계약으로 세웠더니 의미가 같은 출력에 발화 전체가 죽었다 (실측). 종료 판별은 "실행할 도구가 있나" 하나로 정규화한다.
발화 접수는 202 + durable 전표(job)다. 전표가 진실이라 프로세스가 죽어도 이어서 처리되고,
실행한 tool 을 전표에 체크포인트로 남겨 재개 시 외부효과를 재실행하지 않는다. 사용자당
동시 1턴 보장은 파이썬 검사가 아니라 partial unique index(where status='running')가 소유한다.
2차 LLM 감사 — 품질 게이트지 보안 경계가 아니다
최종 텍스트는 별도 purpose 라벨의 LLM 이 한 번 더 본다. 판정 스키마가 4분기 조합 강제다 — 허용+내보내기 / 허용+억제(카드가 곧 답이면 텍스트 생략) / 거부+추가 조회 요구 / 거부+되묻기. "허용인데 처방도 함께" 같은 모호한 산출을 스키마 층에서 잘라 호출부가 분기 우선순위를 임의로 정하지 못하게 한다. 감사가 처방으로 고를 수 있는 건 read tool 뿐이고(워크플로·write 제안 금지), 그것도 스키마·프롬프트·집행 3중으로 막는다.
중요한 선긋기: 감사가 늦거나 실패하면 후보를 그대로 내보낸다. 보안은 카탈로그 화이트리스트·sealed 봉투·taint 표식·승인 게이트가 지고, 감사는 품질 게이트다 — 감사 실패로 사용자 턴을 죽이지 않되, 폴백은 침묵하지 않고 기록한다.
관찰 명제
- LLM 은
프롬프트 → 텍스트순수 함수로 격리하고, 상태·실행권·루프는 전부 서버가 소유한다. tool_calls 는 실행이 아니라 제안이다. - 실행권의 실체는 화이트리스트 dict 하나다. 모델이 무슨 이름을 지어내든
tools.get(name)이 None 이면 아무 일도 안 일어난다. - 되먹임 프롬프트는 표현이 계약이다. 결과를 어떻게 렌더하느냐가 모델의 이탈률을 실제로 바꾼다 — JSON 덤프 대신 라벨 블록, "이미 제공됐다"의 명시.
- write 는 tool 층이 아니라 승인 바인딩 층에서 막는다. 모델 노출은 read 조회뿐이고, write tool 은 사람이 승인한 실행 원장에 못박혀서만 돈다.
- 선언에 기본값을 주지 않는 것이 보안 설계다. 접근 범위 무선언 = 생성 불가, 원문 직송 선언 = receipt·자기신고 강제 — "빠뜨림"과 "결정"을 코드에서 구분한다.
- AI 실패는 fallback 산출로 흡수하고, 감사는 보안 경계로 승격시키지 않는다. 각 겹이 자기 책임만 진다.
자란 개념
이 기록에서 자란 상세는 개념 노트가 소유한다 — [[ai-agent]](LLM 순수 함수 격리·화이트리스트 실행권·되먹임 루프), [[prompt-injection]](sealed 봉투·taint·구조적 방어), [[human-in-the-loop]](write 는 승인 바인딩 뒤에서만).
게이트 커널·스테이지 그래프 쪽 구조는 [[2026-08-28-action-runtime-engine]] 에 있다.