입력, 출력 & 도구 가드레일
입력, 출력 & 도구 가드레일 (Input, Output & Tool Guardrails)
가드레일은 에이전트 실행의 세 가장자리에 검증 계층을 둬요. 모델로 들어가는 길의 프롬프트, 도중에 모델이 하는 도구 호출, 호출자로 나가는 길의 출력이에요. 구조화되지 않은 입력이나 출력이 행동되기 전에 걸러져야 할 때 손대세요. 보내고 싶지 않은 프롬프트 인젝션 시도, 반드시 삭제해야 하는 PII, 싸게 거부하고 싶은 주제 벗어난 요청, 또는 보여주기 전에 출처를 인용해야 하는 답변이요. 가드레일이 없으면 프레임워크가 사용자가 친 것과 모델이 만든 것을 그대로 보내고 반환해요. 가드레일은 최종 발언권을 가진 당신이 제어하는 callable을 사이에 끼워요.
출처: 문서
Pydantic AI Harness가 0.x 릴리스인 동안 API는 마이너 릴리스 사이에 바뀔 수 있어요. 그럴 때는 deprecation 경고와 릴리스 노트의 마이그레이션 안내가 정확히 어떻게 업그레이드할지 알려줘요. 버전 정책을 참고하세요.
본문
문제 (The problem)
에이전트는 사용자로부터 구조화되지 않은 입력을 받고 호출자에게 구조화되지 않은 출력을 반환해요. 스스로는 프레임워크가 "이건 보내기 안전하지 않아" 또는 "이건 보여주기 안전하지 않아"를 추론하지 않아요. 프롬프트 인젝션 시도가 그대로 모델에 닿고, 모델이 만든 어떤 출력이든 그대로 반환돼요. 값을 검사하고 다음에 무엇이 일어날지 결정할 장소가 필요해요.
해결책 (The solution)
세 capability — InputGuardrail, OutputGuardrail, ToolGuardrail — 각각 당신이 공급하는 guard callable을 감싸요. 가드는 값을 검사하고 다섯 결과 중 하나를 반환해요. 실행의 두 가장자리에 대해:
| 결과 | InputGuardrail |
OutputGuardrail |
|---|---|---|
| allow | 프롬프트를 모델에 보냄 | 출력을 호출자에 반환 |
| block | 모델 호출 건너뜀; 거부 메시지가 응답이 됨 | OutputBlocked를 일으킴 |
| replace | 모델에 보내는 프롬프트 다시 씀(삭제) | 정리된 출력 대체 |
| retry | -- (입력에 유효하지 않음) | 출력을 모델에 다시 보내 시도 |
| approve | -- (입력에 유효하지 않음) | -- (출력에 유효하지 않음) |
ToolGuardrail은 도구 호출 양쪽에서 같은 결과를 써요. Tool calls 참고. 입력 block과 출력 block 사이의 비대칭은 의도적이에요. 입력을 차단하면 토큰이 들지 않아 우아한 거부가 거의 항상 맞아요. 출력을 차단하면 모델이 노출되길 원하지 않는 것을 이미 만들었다는 뜻이라, 일으켜서 호출자가 다음에 뭘 할지 결정하게 해요.
InputGuardrail, OutputGuardrail, 그리고 지원 타입 모두 최상위 export예요.
from pydantic_ai import Agent
from pydantic_ai_harness import GuardrailResult, InputGuardrail, OutputGuardrail
def no_secrets(prompt: str) -> bool:
return 'api_key' not in prompt.lower()
def no_pii(output: object) -> GuardrailResult:
if 'SSN' in str(output):
return GuardrailResult.block('The response contained personal data.')
return GuardrailResult.allow()
agent = Agent(
'openai:gpt-5.4',
capabilities=[
InputGuardrail(guard=no_secrets),
OutputGuardrail(guard=no_pii),
],
)
가드는 단순한 경우 베어 bool(True = allow, False = block)이나 더 풍부한 결과를 위해 GuardrailResult를 반환해요. 가드는 async일 수도 있어요. awaitable bool/GuardrailResult를 반환해서 조정 API를 부르는 경우예요.
OutputGuardrail은 출력을 그대로 받아요. 자동 문자열화 없음. 문자열 출력이면 가드가 그것을 직접 읽고, 타이핑된(Pydantic 모델) 출력이면 가드가 모델 인스턴스를 받아 검사에 맞는 직렬화를 고르게 해요(필드 읽기, 또는 JSON 텍스트를 위한 output.model_dump_json() 호출). 이것은 str(MyModel(...))가 필드 콘텐츠를 정규식 검사에서 숨기는 MyModel(field=...) repr을 만드는 함정을 피해요.
가드 여러 개 동시에 (Several guards at once)
InputGuardrail.guard와 OutputGuardrail.guard는 callable 하나 또는 그것의 시퀀스를 받고, ToolGuardrail의 guard와 result_guard는 callable 하나씩 받아요. 체인에서 가드가 순서대로 실행되고, 다음에 일어나는 것은 판정에 달려요.
| 판정 | 체인에 대한 효과 |
|---|---|
allow |
다음 가드로 이동 |
replace |
체인의 나머지가 대체된 값을 검사 |
block / retry |
체인이 거기서 끝남, 둘 다 판단할 값을 남기지 않으므로 |
replace가 앞으로 실밥질되는 것이 순서를 의미 있게 해요. 삭제기를 먼저 두면 그 뒤의 모든 것이 정리된 텍스트를 봐요.
from pydantic_ai import Agent
from pydantic_ai_harness import InputGuardrail
from pydantic_ai_harness.guardrails.detectors import blocked_keywords, redact_secrets
agent = Agent(
'openai:gpt-5.4',
capabilities=[InputGuardrail(guard=[redact_secrets, blocked_keywords(['internal-only'])])],
)
두 검사를 쥔 InputGuardrail 하나는 InputGuardrail capability 둘과 같지 않아요. 체인은 capability 목록의 한 곳, 하나의 순서 결정, 한 벌의 스팬이고, 체인만 삭제를 뒤따르는 검사로 실밥질해요.
삭제는 체인이 끝나야 실행의 메시지 히스토리에 닿아요. 나중 block이 InputGuardrail이 정리된 프롬프트를 다시 쓰기 전에 끝내므로 원본이 히스토리에 남아요. 그게 중요하면 삭제기를 마지막에 두고, 그 앞 검사가 원본 텍스트를 보는 대가로요.
빈 시퀀스는 거부돼요. 가드레일이 처음 실행될 때, 구성될 때가 아니라요. 아무것도 검사하지 않는 가드레일은 구성된 것처럼 읽히고 없는 것처럼 행동하는데, 조용한 통과보다 오류가 나을 가치가 있어요. 집합과 일회성 이터레이터도 같은 방식으로 거부돼요. 집합은 체인이 실행할 순서가 없고, 이터레이터는 첫 요청 후 소진되는데 체인이 요청마다 재구성되니까요.
준비된 감지기 (Ready-made detectors)
pydantic_ai_harness.guardrails.detectors는 다시 쓰게 될 검사를 담아요. GuardrailResult를 반환하는 평범한 함수라 당신의 것 옆 체인에 들어가요.
from pydantic_ai_harness.guardrails import detectors
detectors.redact_secrets # rewrites vendor API keys, tokens, and whole private-key blocks out of text
detectors.redact_personal_data # rewrites emails, card numbers, IBANs, US SSNs
detectors.blocked_keywords(['internal-only']) # refuses text containing any of them
두 삭제기는 거부하는 게 아니라 다시 써요. 그것이 유용한 기본이에요. 키를 인용한 에이전트는 여전히 작업을 끝냈고, 답을 차단하면 잃으면서 키는 어느 쪽이든 메시지 히스토리에 남으니까요.
각각은 기본으로 호출된 secret_data()와 personal_data()예요. 좁히거나 확장해야 할 때 그 팩토리를 직접 쓰세요.
from pydantic_ai_harness.guardrails.detectors import secret_data
secret_data(only=['aws_access_key', 'private_key'])
secret_data(extra={'internal_ticket': r'INT-\d{4}'})
secret_data(placeholder='***') # the default is `[redacted:{name}]`, which says what it removed
감지기는 텍스트를 읽어 프롬프트에 직접 맞아요. 에이전트 출력은 모델 인스턴스일 수 있고, 스크러빙된 문자열을 하나로 대체하면 타입이 바뀌므로 for_text가 무엇이 일어나야 하는지 말하게 해요.
from pydantic_ai_harness import OutputGuardrail
from pydantic_ai_harness.guardrails.detectors import for_text, redact_secrets
OutputGuardrail(guard=for_text(redact_secrets)) # raises on a non-string output
OutputGuardrail(guard=for_text(redact_secrets, on_other='allow')) # skips it deliberately
도구 결과 가드는 값이 아니라 ToolResultInfo를 받아요. for_tool_result_text를 써서 베어 문자열 결과나 ToolReturn의 텍스트 채널에 감지기를 적응시켜요.
from pydantic_ai_harness import ToolGuardrail
from pydantic_ai_harness.guardrails.detectors import for_tool_result_text, redact_secrets
ToolGuardrail(result_guard=for_tool_result_text(redact_secrets))
어댑터는 문자열 return_value와 content의 모든 텍스트를 검사해요. core가 별도 사용자 프롬프트 부분으로 보내는 것이에요. 모델이 하나의 스팬으로 읽는 텍스트 부분은 하나의 문자열로 샌니타이즈돼, content=[a, b]가 content=a + b와 같은 처리를 받고 둘에 걸친 비밀이 여전히 잡혀요. 모델 콘텐츠가 없는 부분은 스팬을 끝내지 않아요. 두 텍스트 사이의 CachePoint는 캐싱 없는 프로바이더가 버려 텍스트가 이어지지만, 그 사이의 이미지·문서는 실제 콘텐츠에 걸쳐 이으면 인접성을 만들어내므로 아니에요.
스팬은 부분을 유지하고 각 부분은 자체 메타데이터를 유지해, 하나하나 샌니타이징이 전체 스팬을 샌니타이징하는 것과 이미 같을 때 그래요. 서로 마주치는 부분은 둘이 다른 경우예요. 한 부분 끝의 키와 다음 시작의 단어가 모델에게 하나의 토큰이라, 그 스팬은 전체 스팬 결과를 지니는 단일 부분으로 접혀요. 접힌 스팬의 CachePoint는 버리지 않고 유지돼요. 첫 텍스트 전이나 마지막 뒤의 것은 그쪽을 유지하고, 병합된 두 텍스트 사이의 것은 표시했던 분할을 잃고 병합된 텍스트 앞으로 이동해 캐시된 접두사를 넓히는 게 아니라 좁혀요. 어느 채널이든 삭제할 때 ToolReturn 메타데이터, kind, 비텍스트 콘텐츠를 보존해요.
for_text처럼 비텍스트 결과는 기본적으로 오류를 일으켜요. on_other='allow'는 부분이 아니라 전체 결과를 건너뛰어요. return_value도 content도 스캔되지 않아요. return_value가 구조화된 ToolReturn은 비텍스트 결과로 세서 그 옵션 아래 content의 민감 텍스트를 스캔하지 않아요. 옵트아웃은 결과에 대한 책임을 호출자가 지는 거예요. 거기서 content를 스캔하면 결과를 허용하는 것으로 문서화된 분기에서 삭제를 반환할 테고, ToolReturn이 아닌 구조화 결과는 스캔할 별도 텍스트 채널이 없으니 여전히 고르지 않을 거예요. 결과가 민감 content를 실을 수 있으면 on_other를 기본으로 두고 대신 출력의 필드에 감지기를 적용하세요.
입력 쪽 감지기는 텍스트 프롬프트가 필요해요. 멀티모달 프롬프트는 텍스트로 렌더링되어 가드에 도달해, 하나와 일치하는 감지기가 붙은 부분을 버리는 대신 replace를 반환하고 InputGuardrail이 거부해요(아래 "Redaction (replace)" 참고). 프롬프트가 첨부를 실을 수 있으면 출력을 가드하세요.
형태만으로 충분하지 않은 곳. email은 주소처럼 보이는 어떤 것이든 일치해요. 그것이 주소가 가진 전부니까요.
from pydantic_ai_harness.guardrails.detectors import redact_personal_data
redact_personal_data('git clone [email protected]:pydantic/pydantic-ai.git')
# replaces `[email protected]`, leaving a command that no longer runs
입력 가드는 프롬프트를 제자리에서 다시 써 모델이 깨진 버전을 받아요. 코드나 경로를 다루는 에이전트에서는 personal_data(only=['us_ssn', 'credit_card', 'iban'])를 쓰거나, 프롬프트가 아니라 출력에 감지기를 두세요. credit_card는 덜 심해요. ISO/IEC 7812가 허용하는 1319 자릿수를 어떤 그룹화로든 다루고, 선두 자릿수가 26일 때만이에요. 그것이 결제 카드가 시작하는 것이고 밀리초 타임스탬프가 아니죠. 모든 일치는 Luhn 알고리즘으로 검사되어 남은 실행을 대부분 버려요. 전부는 아니에요. 10번 중 대략 한 번의 연속 4년 실행이 우연히 체크섬을 만족해서, 연도를 나열하는 프롬프트가 하나 잃을 수 있어요.
iban은 같은 문제와 같은 답을 가져요. 국가 코드 + 두 자릿수가 평범한 텍스트가 끊임없이 치는 형태라 공백은 인쇄된 형태가 두는 곳에서만 허용되고, 모든 일치는 IBAN이 지니는 ISO 7064 mod-97 자릿수로 검사돼요.
AWS 시크릿 액세스 키는 기본에서 의도적으로 없어요. 구분 접두사가 없는 40자 base64라 값의 어떤 것도 키로 표시하지 않고, 형태만의 패턴은 평범한 base64도 데려갈 테니까요. 하나를 일치시키는 것은 옆에 쓰인 이름에 고정하는 것 — aws_secret_access_key = ... — 이고, 그것이 pydantic-ai-shields가 하는 것이며, 키가 할당으로 쓰인 곳에서만 찾아요. 그 더 좁은 패턴은 여기에 배송되지 않아요. 키가 그런 방식으로 에이전트에 닿으면 extra=로 넘기세요.
only=는 패턴을 선택하지 재정렬하지 않아요. 적용 순서는 각 매핑 계약의 일부예요. iban이 credit_card보다 먼저 실행되어 공백 있는 계좌 번호가 카드로 표시되지 않게 하고, only에 나열한 순서가 뭐든 그 순서를 유지해요.
개인 키는 줄 바꿈이 실제 개행이든 JSON 서비스 계정 파일이나 .env 줄이 지니는 이스케이프된 \n이든 일치해요. 그것이 보통 채팅 창에 닿는 방식이니까요. 종결되든 아니든 하나의 private_key 패턴이지 두 이름이 아니라, only=['private_key']가 완전한 블록을 선택하고 END 표시 없이 붙여넣은 키를 삭제 안 한 채 두게 하지 못해요.
이것들이 하지 않는 것. 정규식은 자격 증명을 찾아요. 자격 증명이 형태를 가지니까요. 정규식은 프롬프트 인젝션을 찾지 못해요. 그것은 평범한 언어니까요. 맥락도 이해하지 못해서 삭제기가 실제로 키처럼 보이는 문자열을 때때로 잡아요. 그것들은 값싼 하나의 계층이지 답이 아니에요.
GuardrailResult
GuardrailResult를 원시 필드가 아니라 클래스메서드로 구성하세요.
from pydantic_ai_harness import GuardrailResult
GuardrailResult.allow() # let the value through
GuardrailResult.block('reason') # refuse; `reason` is optional (a default is used otherwise)
GuardrailResult.replace(cleaned_value) # substitute a sanitized value and continue
GuardrailResult.retry('instruction') # ask the model to redo the output or the tool call
GuardrailResult.approve() # ToolGuardrail arguments only: defer the call for human approval
block/retry 메시지는 가드가 결정하는 순간 생성돼, 구성 시 고정된 문자열이 아니라 가드 자체 추론을 실을 수 있어요.
삭제 (replace)
거부하는 대신 샌니타이즈하려면 GuardrailResult.replace(value)를 반환해요. InputGuardrail은 모델에 보내는 프롬프트를 다시 쓰고, OutputGuardrail은 호출자에 반환하는 출력을 대체해요.
def scrub_emails(text: str) -> GuardrailResult:
cleaned = EMAIL_RE.sub('[email]', text)
return GuardrailResult.replace(cleaned) if cleaned != text else GuardrailResult.allow()
agent = Agent(
'openai:gpt-5.4',
capabilities=[
InputGuardrail(guard=scrub_emails), # strip PII before it reaches the model
OutputGuardrail(guard=scrub_emails), # strip PII before it reaches the caller
],
)
입력 삭제는 순차 모드를 요구해요. parallel=True와 호환되지 않아요. 병렬 가드가 원본 프롬프트로 이미 시작된 모델 호출과 함께 실행되니까요. 텍스트 프롬프트도 요구해요. 가드는 멀티모달 프롬프트(agent.run_sync(['describe this', BinaryContent(...)]))를 텍스트로 렌더링해 보고, 여러 부분으로 만든 프롬프트 위에 하나의 문자열을 다시 쓰면 붙은 이미지·문서·오디오를 버릴 테라 replace가 거기서 UserError를 일으켜요. 그런 프롬프트에는 allow나 block을 반환하거나 출력을 가드하세요.
재시도 (retry)
OutputGuardrail은 나쁜 출력을 차단하는 대신 모델에 다시 보낼 수 있어요. GuardrailResult.retry(instruction)을 반환해요. instruction은 모델이 보는 재시도 프롬프트예요. 이것은 pydantic-ai의 정상 재시도 기계를 재사용하고 실행의 출력 재시도 예산에 세요.
def must_cite_sources(output: object) -> GuardrailResult:
if not has_citations(output):
return GuardrailResult.retry('Include at least one source citation.')
return GuardrailResult.allow()
OutputGuardrail(guard=must_cite_sources)
실행 컨텍스트 접근 (Accessing run context)
가드는 실행 상태가 필요하면 첫 매개변수로 RunContext를 받을 수 있어요. deps는 테넌트·역할 인지 정책, 메시지 히스토리는 대화 인지 검사용이에요. 매개변수는 시그니처에서 감지되므로 프롬프트 전용 가드는 선언할 필요 없어요.
from pydantic_ai import RunContext
from pydantic_ai_harness import InputGuardrail
def tenant_policy(ctx: RunContext[MyDeps], prompt: str) -> bool:
return ctx.deps.tier == 'pro' or 'advanced-feature' not in prompt
InputGuardrail(guard=tenant_policy)
병렬 입력 가드 (Parallel input guards)
느린 가드(LLM 분류기, 네트워크 호출)를 순차로 실행하면 그 지연을 매 턴에 더해요. parallel=True를 설정하면 대신 모델 호출과 동시에 가드를 실행해 둘을 겹쳐, 통과 경로에서 가드가 지연을 더하지 않게 해요. 가드가 위반을 보고하는 순간 모델 호출이 취소돼요.
InputGuardrail(guard=slow_async_classifier, parallel=True)
병렬 모드는 지연과 토큰을 맞바꿔요. 순차 모드는 가드가 차단하면 결코 모델을 부르지 않지만, 병렬 모드는 모델 호출을 이미 시작해요. 가드가 모델이 응답한 후에만 발동하면 그 토큰은 쓰였어요. 빠른 로컬 검사(정규식, 키워드 조회)에는 순차가 더 나은 기본이에요. replace는 parallel=True 아래에서는 쓸 수 없어요.
하드 실패 경로 (Hard-fail path)
block이 우아한 경로예요. 대신 예외를 보게 하려면 가드에서 일으키세요.
from pydantic_ai_harness import InputBlocked
def strict_guard(prompt: str) -> bool:
if contains_credentials(prompt):
raise InputBlocked('credentials detected')
return True
가드가 일으키는 어떤 예외든 그대로 전파돼요. 이 모듈의 InputBlocked/OutputBlocked/ToolBlocked 또는 당신의 예외 타입을 쓰세요. ToolBlocked는 tool_name과 선택적 reason을 실어요.
도구 호출 (Tool calls)
ToolGuardrail은 도구 호출 양쪽을 검사해요. guard는 도구가 실행되기 전에 검증된 인수를 보고, result_guard는 모델이 보기 전에 무엇이 반환됐는지 보아요.
from pathlib import Path
import httpx
from pydantic_ai import Agent
from pydantic_ai_harness import GuardrailResult, ToolGuardrail
from pydantic_ai_harness.guardrails import ToolCallInfo, ToolResultInfo
WORKSPACE = Path('/workspace')
def stay_in_the_workspace(call: ToolCallInfo) -> GuardrailResult:
if call.name == 'write_file':
# `resolve()` before the containment check: a prefix test on the raw
# string accepts `/workspace/../etc/passwd`.
target = Path(str(call.args['path'])).resolve()
if not target.is_relative_to(WORKSPACE):
return GuardrailResult.block(f'{target} is outside the workspace.')
return GuardrailResult.allow()
def scrub_secrets(info: ToolResultInfo) -> GuardrailResult:
# Text only. `str()` on a structured result or a `ToolReturn` yields a repr, and
# replacing it with that string would change the result's type -- but only on the
# calls where the pattern happened to match.
if not isinstance(info.result, str):
return GuardrailResult.allow()
cleaned = SECRET_RE.sub('[redacted]', info.result)
return GuardrailResult.replace(cleaned) if cleaned != info.result else GuardrailResult.allow()
agent = Agent(
'openai:gpt-5.4',
capabilities=[ToolGuardrail(guard=stay_in_the_workspace, result_guard=scrub_secrets)],
)
@agent.tool_plain
def write_file(path: str, content: str) -> str:
Path(path).write_text(content)
return f'wrote {path}'
@agent.tool_plain
def fetch_page(url: str) -> str:
return httpx.get(url).text
결과는 병렬 메커니즘이 아니라 Pydantic AI 제어 흐름에 매핑돼요.
| 결과 | guard (인수) |
result_guard (결과) |
|---|---|---|
| allow | 도구 실행 | 결과 그대로 반환(가드는 도구가 만든 객체를 받으므로 읽고 변형하는 대신 replace 써요) |
| block | 실행 건너뜀; 거부 메시지가 도구 결과가 됨(SkipToolExecution) |
거부 메시지가 결과 교체 |
| replace | 대체 인수(매핑)로 도구 실행; 메시지 히스토리에 기록된 호출은 모델의 원본 인수를 유지. 교체가 도구 시그니처와 일치하길 신뢰: 도구가 받지 않는 키는 키워드 인수로 닿아 도구도 가드도 이름붙이지 않는 베어 TypeError를 일으킴 |
정리된 결과 대체 |
| retry | 모델에 호출 재작업 요청(ModelRetry) |
모델에 호출 재작업 요청(ModelRetry); 도구가 이미 한 번 실행돼 부작용이 일어났고 재시도가 다시 실행 |
| approve | 인간 승인을 위해 호출 연기(ApprovalRequired) |
-- (도구가 이미 실행됨) |
block은 두 단계 모두에서 우아해요. 에이전트가 도구 결과를 기대한 곳에서 거부 텍스트를 보고 거부를 설명하거나 다른 접근을 시도할 수 있어요. 대신 실행을 실패시키려면 가드에서 ToolBlocked를 일으키세요.
인간 인 루프 (Human in the loop)
Pydantic AI가 이미 승인 왕복을 소유해요. ApprovalRequired를 일으키는 호출은 뒤로 잡히고, 실행이 DeferredToolRequests 출력으로 끝나며, 인간의 답으로 재개해요. ToolGuardrail은 두 번째 메커니즘을 발명하는 게 아니라 그것에 꽂혀, 가드가 요청한 승인과 requires_approval=True로 표시된 도구가 같은 곳에 도착해요.
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, ToolDenied
from pydantic_ai_harness import GuardrailResult, ToolGuardrail
from pydantic_ai_harness.guardrails import ToolCallInfo
def confirm_production(call: ToolCallInfo) -> GuardrailResult:
if call.args.get('env') == 'prod':
return GuardrailResult.approve()
return GuardrailResult.allow()
agent = Agent(
'openai:gpt-5.4',
capabilities=[ToolGuardrail(guard=confirm_production)],
output_type=[str, DeferredToolRequests],
)
@agent.tool_plain
def deploy(env: str) -> str:
return f'deployed to {env}'
deferred = await agent.run('deploy the new build')
if isinstance(deferred.output, DeferredToolRequests):
approvals = {
call.tool_call_id: True if operator_says_yes(call) else ToolDenied('not on a Friday')
for call in deferred.output.approvals
}
final = await agent.run(
message_history=deferred.all_messages(),
deferred_tool_results=DeferredToolResults(approvals=approvals),
)
거부는 도구의 결과로 모델에 닿아 에이전트가 스스로 설명하거나 다른 것을 시도해요. 재개된 실행에서 가드는 다시 평가되고, 인간이 이미 지운 호출에는 approve가 no-op이 돼요. 다른 모든 판정은 여전히 적용되어, 마음이 바뀐 정책이 승인된 호출도 여전히 차단할 수 있어요.
두 승인 형태와 어느 것을 손댈지:
Deferred (GuardrailResult.approve()) |
In-process (async 가드) | |
|---|---|---|
| 실행 | 끝나고 message_history에서 재개 |
열린 채, 도구 호출이 기다림 |
| 맞는 곳 | HTTP API, 큐, durable execution, 프로세스를 열어둘 수 없는 것 | CLI, TUI, 데스크톱 앱, 운영자에게 웹소켓 |
| 인간 답 | DeferredToolResults(True, ToolDenied, ToolApproved(override_args=...)) |
가드가 반환하는 무엇이든 |
인 프로세스 형태는 추가가 필요 없어요. 가드가 async일 수 있으니 인간을 직접 await할 수 있어요.
async def ask_the_operator(call: ToolCallInfo) -> GuardrailResult:
if await operator_approves(call.name, call.args):
return GuardrailResult.allow()
return GuardrailResult.block('The operator declined this action.')
ToolGuardrail(guard=ask_the_operator)
Pydantic AI는 가드 없이도 승인을 제공해요. 도구의 requires_approval=True, 또는 toolset 전체에 대한 동기 술어를 위한 ApprovalRequiredToolset이요. 결정이 async이거나 deps가 필요하거나 다른 판정 옆에 앉아야 할 때 ToolGuardrail을 손대세요.
두 필드가 가드가 보는 것을 좁혀요.
ToolGuardrail(
guard=stay_in_the_workspace,
tools=['write_file', 'run_shell'], # guard only these; None (default) guards every tool
hidden=['delete_everything'], # withhold these from the model entirely
)
hidden은 이름만 좋은 블록리스트가 아니에요. 숨겨진 도구는 모델에 보내는 정의에서 빼져 토큰이 들지 않고 모델이 결코 시도하지 않아요. 차단된 도구는 보이고 모델이 거부됐다는 것을 배워요. 숨김은 정적 이름 목록을 받아요. deps나 인수에 의존하는 정책은 guard를 쓰세요.
구성된 hidden 이름은 실행이 성공적으로 끝나면 검사돼요. 구성된 tools 이름은 guard나 result_guard가 설정됐을 때만 그때 검사돼요. 동적 toolset이 한 스텝에 도구를 생략하고 나중에 제공할 수 있어 결코 나타나지 않는 이름이 유일한 오타 신호예요. 그 경고가 tools=['send_monye']를 잡는데, 아니면 의도한 도구를 무가드로 남기고, 오타난 hidden 이름도 잡아 아니면 모델에 보인 채로 남아요.
도구 가드가 보지 못하는 것
세 종류의 호출은 실행 훅에 결코 닿지 않아 guard도 result_guard도 그들에 대해 상담되지 않아요.
- 출력 도구, 에이전트의 구조화 출력을 만드는 것. 그건
OutputGuardrail로 걸러요. - 외부·지연 도구, 실행이
DeferredToolRequests에서 애플리케이션에 되돌려주는 것. Pydantic AI는 어떤 실행 훅이든 실행되기 전에 그것을 거부해, 가드가 인수를 검토할 수 없어요. 당신의 애플리케이션이 실행하는 것이고 검사는 거기 속해요.hidden은 그것을 다뤄요. 도구 정의에서 작동하니까요. - 프로바이더 측 내장 도구 웹 검색 같은 것. 프로바이더 안에서 실행되고 도구 실행이 아니라 내장 호출/반환 부분으로 돌아와요.
ToolGuardrail은 이 실행이 실행하는 도구에 대한 통제예요. 실행하지 않는 것에는 hidden이 여전히 적용되는 레버예요.
스트리밍 (Streaming)
OutputGuardrail은 최종 출력만 검사해요. run_stream() 동안 부분 청크가 가드가 실행되기 전에 호출자에 닿아, block이나 replace 판정이 이미 스트리밍된 콘텐츠를 보내지 않게 할 수 없어요. 출력이 노출되기 전에 걸러야 하면 run()/run_sync()을 쓰세요. GuardrailResult.retry()는 run_stream() 아래에서 지원되지 않고 거기서 UnexpectedModelBehavior로 드러나요. InputGuardrail(parallel=True 포함)은 스트리밍·비스트리밍 실행에서 같게 작동해요.
추적 (Tracing)
replace와 block은 활성 OpenTelemetry 트레이서의 스팬으로 기록돼, 삭제나 거부가 Logfire 트레이스(guardrail redacted input, guardrail blocked output 등)에 guardrail.* 속성과 함께 나타나요. 콘텐츠 속성 — 삭제의 원본/대체 값과 block의 거부 message — 은 RunContext.trace_include_content가 활성일 때만 붙어요. 그것이 트레이스 밖에 두려는 바로 그 콘텐츠를 인용할 수 있으니까요.
도구 스팬은 도구를 이름붙이는 guardrail.tool 속성을 더해요. approve는 guardrail deferred tool args를 기록하는데, 항상 guardrail.tool_call_id를 실어 애플리케이션이 답하는 DeferredToolRequests와 스팬을 상관시킬 수 있고, guardrail.arguments를 다른 콘텐츠 속성과 같은 trace_include_content 규칙 아래 실어 나라요. 지연 호출은 결코 실행되지 않아 무엇을 요청했는지 기록하는 execute_tool 스팬이 없어요.
OutputGuardrail은 capability 순서와 무관하게 감싸는 Instrumentation 스팬이 항상 잡도록 block/redact 스팬을 배치하고, InputGuardrail은 가장 안쪽으로 실행돼 메시지를 변형하는 어떤 capability(프롬프트 다시 쓰기, 컨텍스트 매니저)든 먼저 실행되고 가드가 모델이 받을 최종 프롬프트를 보게 해요. ToolGuardrail도 가장 안쪽으로 실행돼 인수 훅 중 마지막(다른 capability가 수정을 끝낸 인수를 봐요)이고 결과 훅 중 첫(가드가 ToolOutputLimits 같은 capability가 자르거나 떼어내기 전에 원시 도구 결과를 봐요)이에요.
pydantic-ai-shields와의 관계
pydantic-ai-shields는 각 감지기를 자체 capability로 배송해요. 여기의 등가는 대신 함수라, 여러 개가 하나의 체인으로 실행되고, 삭제를 공유하고, 당신이 쓴 가드 옆에 앉을 수 있어요.
그것이 갖고 이게 의도적으로 갖지 않는 두 가지. 그 PromptInjection은 "ignore previous instructions" 같은 구절을 일치시켜요. 인젝션은 평범한 언어라 패턴 목록이 예시를 잡고 공격을 놓치면서 붙여넣은 로그를 표시해요. 보호인 것처럼 읽히면서 아닌 검사는 없는 것보다 나빠요. 그 NoRefusals는 모델이 거절하는 것을 차단해요. 그것은 에이전트가 무엇을 말할 수 있는지에 대한 결정이지 데이터 가드레일이 아니고, 기본으로 만들 결정이 아니에요.
API
InputGuardrail(
guard, # one guard, or a sequence run in order
parallel=False, # run concurrently with the model call
)
OutputGuardrail(
guard, # one guard, or a sequence run in order
)
ToolGuardrail(
guard=None, # inspects a ToolCallInfo before the tool runs
result_guard=None, # inspects a ToolResultInfo after it runs
tools=None, # Sequence[str] | None -- restrict both guards to these tool names
hidden=(), # Sequence[str] -- withhold these tools from the model entirely
)
가드 callable은 검사된 값을 받아요. InputGuardrail은 프롬프트, OutputGuardrail은 출력, ToolGuardrail은 ToolCallInfo 또는 ToolResultInfo — 선택적으로 RunContext가 앞에 와요. InputGuardrailFunc, OutputGuardrailFunc, ToolGuardrailFunc, ToolResultGuardrailFunc가 내보내진 시그니처 별칭이고, GuardrailError가 InputBlocked, OutputBlocked, ToolBlocked의 기반이에요.
Source: pydantic_ai_harness/guardrails/.