응답 파싱

응답 파싱 (Response Parsing)

채팅 모델이 단순한 응답 문자열 하나만이 아니라 구조화된 출력을 생성하는 일이 점점 흔해지고 있어요. 예를 들어 추론 모델은 자신의 추론 과정을 담은 사고의 흐름(chain of thought)을 내보낼 수 있고, 도구 호출 모델은 함수 이름과 인자를 내보낼 수 있어요.

출처: 문서

본문

하지만 구조화된 출력에는 문제가 있어요. LLM 출력은 본질적으로 구조화되어 있지 않기 때문이지요. LLM API는 보통 role, content, thinking 같은 키를 가진 메시지 dict를 받고 반환하지만, 내부적으로 LLM은 사실 단일 토큰 시퀀스를 이어나갈 뿐이에요. 우리는 사용자 대상 API와 모델의 실제 토큰 스트림을 연결하는 접착 계층(glue layer)을 사용해요. 입력을 토큰 스트림으로 바꾸기 위해 chat_templates을 사용하는데, 이는 다른 문서에서 다뤄요. 이 문서는 그 접착 계층의 나머지 절반, 즉 모델이 생성한 토큰 출력을 다시 구조화된 응답 dict로 바꾸는 시스템인 응답 템플릿(Response templates) 에 관한 거예요.

여러 면에서 응답 템플릿은 채팅 템플릿의 역(inverse) 연산을 수행해요. 채팅 템플릿에서는 메시지 리스트를 넣으면 모델에 입력할 준비가 된 토큰을 얻지요. 응답 템플릿에서는 원시 모델 출력 토큰을 넣으면 구조화된 메시지를 얻어요. 채팅 템플릿과 마찬가지로, 응답 템플릿은 사용자가 모델이 기대하는 특정 형식과 제어 토큰의 지저분한 세부사항을 무시하고, 어떤 모델에서든 동작하는 보편적인 메시지 dict API를 사용할 수 있게 해줘요.

응답 템플릿을 이해하는 가장 좋은 방법은 실제로 보는 거예요. 주요 진입점은 parse_response() 메서드로, 단일 시퀀스나 배치를 모두 받아요:

from transformers import AutoModelForCausalLM, AutoTokenizer

checkpoint = "HuggingFaceTB/SmolLM3-3B"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForCausalLM.from_pretrained(checkpoint, dtype="auto", device_map="auto")

messages = [{"role": "user", "content": "Summarize the end of the Cold War, very briefly."}]
input_ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt")["input_ids"].to(model.device)
outputs = model.generate(input_ids, max_new_tokens=1024)[0, input_ids.shape[1]:]
out_text = tokenizer.decode(outputs)
print(tokenizer.parse_response(out_text, prefix=input_ids[0]))
# Outputs a structured dict: {"role": "assistant", "thinking": "...", "content": "..."}

tokenizer에 response_template이 있으면 parse_response 메서드는 출력 메시지를 깔끔하게 구조화된 dict로 바꿔서 채팅에 추가할 준비를 해줘요. 주의할 점은 prefix(프롬프트 토큰)도 이 메서드에 전달해야 한다는 거예요. 많은 채팅 템플릿이 모델이 응답을 시작하기 전에 메시지를 시작하거나 생각(thinking) 블록을 열기 때문이에요. 그래서 파서가 메시지를 이해하려면 프롬프트를 봐야 해요. 마지막 턴 이전의 모든 prefix는 버려져요. 우리는 한 번에 하나의 메시지만 파싱해요. prefix가 필요한 건 마지막 메시지 전체를 보고 있는지, 그리고 prefilled 필드를 놓치지 않았는지 확인하기 위해서예요!

prefix가 없으면 prefilled 메시지를 조용히 잘못 파싱할 수 있으므로, prefix는 필수예요. 생략하면 오류가 발생해요. 생성 결과에 이미 완전한 메시지가 포함되어 있고 prefix 컨텍스트가 필요 없다고 확신하는 드문 경우에는 prefix=""(또는 빈 토큰 id 리스트)를 전달해서 명시적으로 선택 해제할 수 있어요.

tokenizer에 응답 템플릿이 설정되어 있지 않으면 parse_response는 오류를 발생시켜요. 가능한 한 빨리 더 많은 모델에 템플릿을 추가하기 위해 노력하고 있어요!

스트리밍 응답 파싱 (Streaming response parsing)

위 예시에서는 생성이 끝난 후 모델 응답을 한 번에 파싱했어요. 하지만 흔히 부분 메시지를 생성되는 대로 파싱하고 싶을 때가 있어요. 특히 모델이 끝날 때까지 1~2분 동안 정적 페이지를 표시하고 싶지 않은 사용자 대상 앱에서 그렇지요.

스트리밍 파싱이 필요하면 tokenizer.get_response_parser()를 호출해서 ResponseParser를 얻어요. parse_response와 마찬가지로 채팅 프롬프트를 prefix=로 전달해서 파서가 채팅 템플릿에 의해 prefilled된 메시지 부분을 알게 해요. 반환된 객체는 상태를 가진(stateful) 파서로, 모델이 텍스트를 생성함에 따라 그 텍스트를 넣어줄 수 있어요:

parser = tokenizer.get_response_parser(prefix=input_ids[0])
for event in parser.initial_events:
    render(event)  # Display the partial message to the user however you want to
for chunk in model_output:
    for event in parser.feed(chunk):
        render(event)
message, final_events = parser.finalize()
for event in final_events:
    render(event)

요청에 도구가 포함되어 있으면 그것도 함께 전달해요(get_response_parser(..., tools=tools)). 그러면 각 영역(region)이 닫힐 때 도구 호출 인자가 호출 도구의 JSON Schema에 따라 타입이 지정되므로, 스트리밍 소비자는 finalize() 이후에만이 아니라 region_close에서 스키마 타입의 인자를 볼 수 있어요. 자세한 내용은 Tool-call 인자 타입 지정을 참고해 주세요.

파서는 생성 과정에서 텍스트가 들어오면 **이벤트(event)**를 내보내요. 이는 현재 어떤 영역이 생성되고 있는지를 나타내요. 영역이 완료되면 완전히 파싱된 내용과 함께 별도의 이벤트로 내보내져요. 생성이 끝나면 finalize() 메서드가 남은 텍스트를 비우고 최종 이벤트와 완전한 메시지 dict를 내보내요.

parse_response는 배치를 받을 수 있지만 스트리밍 파싱은 항상 단일 시퀀스라는 점을 기억하세요. 각 ResponseParser는 하나의 생성 상태를 추적해요. 여러 생성을 동시에 스트리밍하려면 시퀀스마다 하나의 ResponseParser를 만들어요.

스트리밍 이벤트 (Streaming events)

각 스트리밍 파싱 이벤트는 type 키를 가진 dict예요. 세 가지 종류가 있어요:

Type 설명 내용
region_open 모델이 content나 thinking 같은 새 영역을 시작했음을 나타내요. field (str): 필드 이름.
region_chunk 현재 영역에 대한 텍스트 청크. field (str): 필드 이름. text (str): 새 청크. dirty (bool): 청크가 파싱이 필요한 원시 텍스트면 True.
region_close 영역이 끝났고 그 키가 이제 최종 확정되었음을 나타내요. field (str): 필드 이름. value (any): 영역에 대해 완전히 파싱된 값

region_chunk 이벤트는 바이트가 도착할 때마다 모든 영역에 대해 내보내져서, 구조화된 영역에서도 스트리밍 UI가 진행 상황을 렌더링할 수 있어요. 텍스트류 영역(text, int, float, bool)의 청크는 dirty=False로 표시돼요. 각 청크는 이미 최종 값의 일부니까요(닫힐 때 끝의 공백은 제거됨). JSON 형식의 도구 호출 같은 구조화된 영역의 청크는 dirty=True로 표시돼요. 즉 텍스트가 원시적이고 아직 파싱되지 않은 본문이라는 뜻이에요. 점진적으로 표시하는 건 안전하지만, 파싱된 값(dict, list 등)은 해당 region_close 이벤트에서만 도착해요. 어느 쪽이든 영역의 최종 값은 항상 region_close가 전달하므로, 중간 렌더링을 신경 쓰지 않는 소비자는 region_chunk 이벤트를 그냥 무시해도 돼요.

채팅 prefix가 메시지에 무엇인가를 썼다면(예: 템플릿이 생각 블록을 열었거나, assistant prefill이 모델에게 넘기기 전에 응답을 시작했거나), 파서는 그 이벤트들을 parser.initial_events로 노출해요. 이는 모델 출력을 넣기 전에 여러분의 렌더러에 재생할 수 있는 리스트예요. prefix 안에서 열리고 닫힌 영역은 전체 region_open / region_chunk / region_close 시퀀스를 생성하고 그 파싱된 값은 출력 dict에 들어와요. 마치 모델 자신이 쓴 것과 똑같지요.

전형적인 이벤트 스트림은 이렇게 생겼어요:

{"type": "region_open",  "field": "thinking"}
{"type": "region_chunk", "field": "thinking", "text": "I should ", "dirty": False}
{"type": "region_chunk", "field": "thinking", "text": "greet the user", "dirty": False}
{"type": "region_close", "field": "thinking", "value": "I should greet the user"}
{"type": "region_open",  "field": "tool_calls"}
{"type": "region_chunk", "field": "tool_calls", "text": '{"name": "greet_user", ', "dirty": True}
{"type": "region_chunk", "field": "tool_calls", "text": '"arguments": {"greeting": "Hi!"}}', "dirty": True}
{"type": "region_close", "field": "tool_calls", "value": {"type": "function", "function": {"name": "greet_user", "arguments": {"greeting": "Hi!"}}}}

thinking이 dirty=False로 내보내지는 걸 주목하세요. thinking이나 content 같은 필드는 보통 그냥 원시 텍스트이기 때문이에요. 즉 청크를 유효한 "부분 출력"으로 취급할 수 있어요. 하지만 tool_calls는 dirty로 표시되는데, 원시 텍스트에 상당한 정리가 필요하기 때문이에요. 도구 호출은 흔히 JSON이나 다른 형식으로 파싱된 다음 재구성되어 최종 도구 호출 dict를 만들어야 하지요. 그래서 이 영역들의 최종 출력은 원시 텍스트와 아주 매우 달라 보이는 경우가 많아요. 이 최종 파싱은 region_close에 도달했을 때만 일어나요. 그때까지 dirty 청크로 무엇을 할지는 여러분의 몫이에요. 있는 그대로 표시해서 사용자에게 "원시" 출력을 보여주거나, 깨끗한 게 준비될 때까지 그냥 기다려도 돼요.

이 정도면 응답 템플릿을 사용하는 데 필요한 대부분을 알게 됐어요. 이 문서의 나머지는 파싱 시스템의 내부와 응답 템플릿 작성 방법에 집중해요. 이는 주로 개발자와 모델 작성자에게 관련이 있어요. 대부분의 사람은 여기서 멈춰도 안전해요!

고급: 응답 템플릿 작성하기 (Advanced: Writing a response template)

응답 템플릿 작성 방법을 이해하는 가장 좋은 방법은 구체적인 예시를 보는 거예요. SmolLM의 원시 응답은 이렇게 생겼을 거예요:

 thinking
I should greet the user
 response

<tool_call>{"name": "greet_user", "arguments": {"greeting": "Hi!"}}</tool_call>

표준 메시지 dict 형식으로 이 출력을 파싱하면 이렇게 보여야 해요:

{
    "role": "assistant",
    "thinking": "I should greet the user",
    "tool_calls": [
        {"type": "function", "function": {"name": "greet_user", "arguments": {"greeting": "Hi!"}}}
    ]
}

그리고 이것이 그것을 파싱하는 템플릿이에요. 겁먹지 마세요. 많은 부분이 꽤 자명하니까요!

{
    "defaults": {"role": "assistant"},
    "start_anchor": "<|im_start|>assistant\n",
    "fields": {
        "thinking": {"open": " thinking", "close": " response", "content": "text"},
        "tool_calls": {
            "open": "<tool_call>",
            "close": "</tool_call>",
            "repeats": True,
            "content": "json",
            "transform": {"type": "function", "function": "{content}"},
        },
        "content": {
            "close": "<|im_end|>",
            "content": "text",
        },
    },
}

기본적으로 템플릿은 **필드(fields)**와 **구분자(delimiters)**를 정의해요. 각 필드는 출력 dict의 키에 대응해요. 필드는 또한 구분자 안의 텍스트를 파싱하기 위한 정보를 포함해요. 한 가지 미묘한 점이 있어요: content 필드에는 open이 없어요. SmolLM(그리고 다른 여러 모델)에서는 특수 토큰으로 표시되지 않기 때문이에요. 대신 content는 다른 영역 뒤, 시퀀스 끝 앞의 공간에 저장돼요. 우리 템플릿에서는 이를 다른 영역이 차지하지 않은 텍스트를 집어내는 묵시적/잔여(implicit / leftover) 필드로 표현해요.

fields 외에도 템플릿은 두 개의 최상위 키를 지원해요:

  • defaults (선택) — 출력에 미리 채워질 값들의 dict(예: {"role": "assistant"}). 여기 키는 어떤 필드도 쓰지 않았어도 항상 파싱 출력에 유지돼요. 다른 키는 해당 필드가 아무것도 포착하지 못하면 버려져요.
  • start_anchor (str) / start_anchor_pattern (str regex) — 현재 assistant 메시지가 채팅 프롬프트 안에서 시작되는 위치를 표시해요. parse_response나 get_response_parser에 prefix=를 전달하면, 파서는 처리 전에 prefix를 이 앵커의 마지막 발생 지점 너머로 오른쪽 자르기(right-truncate) 해요. 그래서 멀티턴 대화의 이전 턴들이 현재 메시지 상태를 오염시키지 않아요. 앵커는 prefix에만 적용되고, 파싱하는 응답/생성에는 절대 적용되지 않아요. 일부 형식은 메시지 중간에 앵커를 다시 내보내는 게 합법적이기 때문이에요(gpt-oss harmony 출력은 모든 채널을 <|start|>assistant로 열어요). 생성 결과를 앵커 너머로 잘라내면 모델 자신의 추론과 도구 호출이 사라지게 돼요. 그래서 생성 결과만으로는 히스토리 누수(bleed)를 막기에 부족한 거예요. 프롬프트를 prefix=로 전달하세요. ChatML 스타일 모델의 앵커는 보통 "<|im_start|>assistant\n"이에요. start_anchor와 start_anchor_pattern 중 정확히 하나는 반드시 설정해야 해요.

예를 들어 이 멀티턴 prefix에서(두 개의 assistant 턴이 있다는 걸 주목하세요):

<|im_start|>user
Hi<|im_end|>
<|im_start|>assistant
Hello!<|im_end|>
<|im_start|>user
Again?<|im_end|>
<|im_start|>assistant

파서는 마지막 <|im_start|>assistant\n까지의 모든 것을 잘라내서 이전 "Hello!" 턴을 버려요. 규칙은 마지막 assistant 턴을 제외한 모든 것이 항상 버려진다는 거예요.

채팅 템플릿과 마찬가지로 응답 템플릿은 tokenizer 속성으로 저장되고 tokenizer와 함께 저장돼요. 채팅 템플릿과 달리, 별도 파일이 아니라 tokenizer_config.json 안에 저장해요. 응답 템플릿의 형식은 Jinja 스크립트인 채팅 템플릿과 달리 JSON에 자연스럽게 맞기 때문이지요.

tokenizer.response_template = template
tokenizer.save_pretrained(...)  # Written as a key in tokenizer_config.json

고급: 필드 API 참조 (Advanced: Field API Reference)

각 필드는 여러 키를 지원해요. 두 가지 유형으로 나눌 수 있어요. 먼저 필드를 어떻게 포착할지 정의하는 키들:

Key 타입 목적
open str 또는 list[str] 이 영역을 여는 리터럴 문자열. 문자열 리스트는 "이 중 아무거나 매치"를 뜻해요.
open_pattern str (regex) open의 regex 대안. 이름 붙은 그룹이 transform에서 사용 가능한 캡처 변수가 돼요.
close str 또는 list[str] 이 영역을 닫는 리터럴 문자열(또는 문자열 리스트). 생략하면 스트림 끝까지 실행돼요.
close_pattern str (regex) close의 regex 대안. 이름 붙은 그룹이 transform에서 사용 가능한 캡처 변수가 돼요.
repeats bool true면 필드는 리스트이고 각 매치가 추가돼요. 기본 false.
join str repeats와 함께, 리스트 대신 이 구분자로 매치들을 하나의 문자열로 연결해요.
optional bool false이고 영역이 매치되지 않으면 오류를 발생시켜요. 기본 true.

필드는 open이나 open_pattern 중 하나만 가져야 해요. 둘 다는 안 되고, close와 close_pattern도 마찬가지예요.

필드는 close/close_pattern을 아예 생략할 수 있는데, 그러면 영역이 생성 텍스트의 끝까지 열려 있어요. 메시지 끝까지 이어지는 마지막 필드에 유용해요.

open도 open_pattern도 없는 필드는 묵시적(implicit) 필드예요. 명시적 영역이 열려 있지 않을 때마다 활성화되어 잔여 텍스트를 포착해요. 묵시적 필드는 최대 하나만 있을 수 있어요. content에 특수 토큰 태그가 없고 다른 필드 뒤에 평문으로 쓰이는 경우가 가장 흔해요.

여는·닫는 구분자 외에 repeats를 지정할 수도 있어요. 이는 필드가 리스트이고 구분자가 여러 번 매치될 수 있음을 나타내요. 병렬 도구 호출, 즉 모델이 동시에 여러 도구 호출을 내보낼 때 가장 흔해요:

'<tool_call>{"name": "a", ...}</tool_call><tool_call>{"name": "b", ...}</tool_call>'
# Returns `"tool_calls": [{... "a" ...}, {... "b" ...}]` in a template with repeats: true

하지만 모든 반복 필드가 리스트가 되어야 하는 건 아니에요. 어떤 모델은 단일 메시지에 여러 thinking이나 content 블록을 내보내는데, 그런 것의 자연스러운 출력은 하나로 연결된 문자열이에요. join(구분자 문자열, 보통 그냥 "")을 설정하면 repeats 필드가 리스트를 모으는 대신 매치들을 연결하도록 전환돼요:

field = {"thinking": {"open": " thinking", "close": " response", "repeats": True, "join": "\n"}}
input = " thinkingfirst response... thinkingsecond response"
# Returns: {"thinking": "first\nsecond"}

join 필드의 각 매치는 문자열로 파싱되어야 해요. 스트리밍할 때 region_close는 여전히 각 블록의 값을 전달해요. 구분자는 최종 메시지 dict에만 나타나요.

마지막으로 반드시 존재해야 하는 필드에는 optional: false를 지정할 수 있어요. 그런 필드가 없으면 필드가 없는 메시지 dict를 그냥 반환하는 대신 오류를 발생시켜요.

닫는 구분자를 보지 못했더라도 생성이 끝나면 열려 있는 모든 영역을 닫고 최종 확정해요.

필드의 콘텐츠 파싱하기 (Parsing the content of a field)

필드를 포착하는 방법을 정의했으면, 그 포착 안의 원시 텍스트를 어떻게 파싱할지도 지정해야 해요. 이를 제어하는 네 가지 키가 있어요:

Key 타입 목적
content str 이 영역 안의 콘텐츠 타입. 기본 "text". 각 타입은 고유한 파서를 가져요.
content_args dict 이 영역의 콘텐츠 파서에 전달할 인자.
transform dict/list 파싱된 본문을 재구성하는 선택적 사후 파싱 템플릿(Transform 참고).
transform_each bool true면 파싱된 콘텐츠가 리스트여야 하고 transform이 요소별로 적용돼요.

첫 번째(그리고 가장 중요한) 키는 content예요. 필드의 콘텐츠 타입을 나타내며, 필드에 포착된 원시 텍스트를 최종 출력으로 변환하는 데 사용될 파서를 결정해요. content_args는 파서를 구성하는 데 사용되며, 커스텀 코드 없이 다양한 형식의 특이점을 지원하게 해줘요. 각 파서 타입과 그 인자를 차례로 살펴볼게요.

기본 타입 (Basic types)

text, int, float, bool이 기본 타입이에요. 이 콘텐츠 타입들은 모두 공백을 제거한 다음 필요하면 단순 타입 변환을 해요. content_args는 없어요. 단, text는 strip 인자를 지원하는데, 포착된 텍스트의 앞뒤 공백을 제거하고 기본값은 true예요.

field = {"count": {"open": "<n>", "close": "</n>", "content": "int"}}
input = "<n> 42 </n>"
# Returns: {"count": 42}

json

json 파서는 포착된 텍스트를 JSON으로 파싱해요. 도구 호출 인자와 중첩 구조를 가진 다른 모든 것의 핵심(workhorse)이에요. 모델이 실제 세계에서 JSON을 망가뜨리는 다양한 방식을 처리하기 위한 몇 가지 선택적 content_args를 받아요:

  • unquoted_keys (bool, 기본 false): 키 이름이 따옴표 없이 원시로 있을 때 활성화해요(예: {name: "foo"}). 엄격한 JSON 대신 JavaScript 스타일 객체 리터럴을 내보내는 모델에 유용해요.
  • string_delims ([open, close] 쌍의 리스트, 선택): "..." 대신 커스텀 구분자로 문자열 값을 감싸는 모델용. 각 쌍이 여는·닫는 마커를 줘요.
  • allow_non_json (bool, 기본 false): 파싱이 실패하면 오류를 발생시키는 대신 공백 제거된 원시 텍스트를 반환해요. 모델이 보통 JSON을 내보내지만 가끔 평문으로 빠지는 필드의 폴백으로 유용해요.

unquoted_keys와 string_delims는 둘 다 비표준에 가까운, 거의 JSON인 출력을 내보내는 모델을 처리하기 위한 것이므로 소수의 모델에만 필요할 거예요.

field = {"args": {"open": "<args>", "close": "</args>", "content": "json", "content_args": {"unquoted_keys": True}}}
input = '<args>{city: "London"}</args>'
# Returns: {"args": {"city": "London"}}

xml-inline

xml-inline 파서는 XML류 태그의 평평한 시퀀스로 이루어진 영역을 위한 것이에요. 각 태그가 dict의 한 항목이 돼요. 보통 각 인자를 JSON 덩어리 대신 별도의 태그로 내보내는 모델의 tool_calls 필드 안에서 쓰여요:

  • tag_pattern (str, 필수): 단일 태그와 매치하는 regex. 이름 붙은 그룹 key(결과 dict 키)와 value(dict 값이 되는 원시 텍스트)를 포함해야 해요.
  • value_parser (dict, 선택): 각 포착된 value에 적용되는 중첩 콘텐츠 파서. name(파서, 예: "json", "int")과 선택적 args(content_args)를 가진 dict. 생략하면 값은 원시 문자열로 남아요.
  • merge_duplicates (bool, 기본 false): 같은 키가 여러 번 나타나면 나중 매치가 이전 것을 덮어쓰는 대신 값들을 리스트로 모아요.

예를 들어 Qwen3는 각 도구 호출 인자를 별도의 <parameter> 태그로 내보내고, 우리는 다음과 같이 파싱해요:

"tool_calls": {
    "open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>",
    "close": "</tool_call>",
    "repeats": True,
    "content": "xml-inline",
    "content_args": {
        "tag_pattern": r"<parameter=(?P<key>\w+)>\s*(?P<value>.*?)\s*</parameter>",
        "value_parser": {"name": "json", "args": {"allow_non_json": True}},
    },
    "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
}

중첩된 value_parser를 주목하세요. 각 파라미터 값은 그 자체로 json 파서(평문 문자열도 통과시키는 allow_non_json 포함)를 거쳐요. 위 tool_calls 필드에 이 입력을 넣으면:

input = "<tool_call><function=get_weather><parameter=city>London</parameter><parameter=units>celsius</parameter></function></tool_call>"
# Returns: {"tool_calls": [{"type": "function", "function": {"name": "get_weather", "arguments": {"city": "London", "units": "celsius"}}}]}

kv-lines

kv-lines 파서는 줄로 구분된 key: value 쌍을 처리해요(YAML류 메타데이터나 .env 파일을 생각하면 돼요). 각 줄이 결과 dict의 한 항목이 돼요. 모든 인자는 선택이에요:

  • line_sep (str, 기본 "\n"): 쌍 사이의 구분자.
  • kv_sep (str, 기본 ":"): 한 줄 안에서 키와 값 사이의 구분자. 첫 번째 발생만 분할 지점으로 사용되므로 값에 구분자가 포함될 수 있어요.
  • strip (bool, 기본 true): 각 키와 값의 주변 공백을 제거해요.
  • value_parser (dict, 선택): xml-inline과 같은 {"name": ..., "args": ...} 형식으로 각 값에 적용되는 중첩 콘텐츠 파서. 생략하면 값은 원시 문자열로 남아요.

비어 있거나 kv_sep를 포함하지 않는 줄은 조용히 건너뛰어지므로, 포착된 영역의 빈 줄은 허용돼요.

field = {"metadata": {"open": "<meta>", "close": "</meta>", "content": "kv-lines"}}
input = "<meta>name: alice\nage: 30</meta>"
# Returns: {"metadata": {"name": "alice", "age": "30"}}

age가 "30"을 문자열로 유지하는 걸 주목하세요. {"name": "int"}의 value_parser를 추가하면 30으로 파싱돼요.

도구 호출 인자 타입 지정 (Typing tool-call arguments)

때때로 응답 파서가 모델 출력을 잘못된 타입으로 파싱할 수 있어요. 예를 들어 float 1.5를 "1.50"으로 파싱할 수 있지요. 도구가 한 타입의 인자를 기대하는데 다른 타입으로 받으면, 이는 도구 호출에 문제를 일으킬 수 있어요. 이를 피하려면 요청의 tools를 parse_response()나 ResponseParser에 전달해서 각 도구의 JSON Schema parameters를 사용해 캐스팅할 수 있어요. 도구는 apply_chat_template()과 같은 형식으로 받아요: JSON 스키마, 또는 스키마로 자동 변환되는 타입 힌트와 docstring을 가진 Python 함수.

tools = [{
    "type": "function",
    "function": {
        "name": "set_alarm",
        "parameters": {
            "type": "object",
            "properties": {
                "hour": {"type": "integer"},
                "enabled": {"type": "boolean"},
                "label": {"type": "string"},
            },
        },
    },
}]
message = parse_response(model_out, template, prefix="", tools=tools)
# message["tool_calls"][0]["function"]["arguments"] ==
# {"hour": 7, "enabled": True, "label": "wake up"}

Transform

대부분의 필드에서는 transform 키가 불필요해요. 파싱된 본문을 최종 출력으로 재구성해야 하거나, 구분자의 정보를 결과에 병합해야 할 때 사용돼요. 구조가 복잡한 경우가 많은 tool_calls 필드에서 가장 흔히 나타나요.

transform은 템플릿이에요. 출력 구조를 설명하는 dict(또는 리스트)로, "{name}" 형태의 문자열은 해당 값으로 치환돼요. 값은 content(이 영역의 파싱된 본문)와 open_pattern / close_pattern이 포착한 이름 붙은 그룹에서 접근할 수 있어요. 아주 흔한 용도는 function 키로 도구 호출 dict를 바깥 dict로 감싸는 거예요. 이는 우리 표준 도구 호출 형식의 일부이기 때문이지요:

"tool_calls": {
    "open": "<tool_call>",
    "close": "</tool_call>",
    "repeats": True,
    "content": "json",
    "transform": {"type": "function", "function": "{content}"},
},

그래서 이 원시 출력:

<tool_call>{"name": "greet_user", "arguments": {"greeting": "Hi!"}}</tool_call>

이렇게 됩니다(repeats: True가 tool_calls를 리스트로 만들죠):

[{"type": "function", "function": {"name": "greet_user", "arguments": {"greeting": "Hi!"}}}]

"{content}" 같은 전체 문자열 placeholder는 타입이 보존된 조회 값을 반환해요. 그래서 위에서 파싱된 JSON dict가 function의 값으로 직접 들어가요. placeholder는 전체 문자열이어야 해요. 텍스트와 placeholder를 섞는 것("abc {name} def")은 허용되지 않아요. f-string이 아니니까요!

placeholder는 "{content.args}" 같은 점 경로(dotted path)도 취할 수 있어요. 이는 content를 조회한 다음 파싱된 dict의 args 키로 내려가요. 키 이름이 우리 표준 형식과 맞지 않는 도구 호출 본문을 재구성하는 방법이에요. 예를 들어 {"name": ..., "args": ...}를 내보내는 모델은 args를 arguments로 이름 바꿔야 해요:

"tool_calls": {
    "open": "<tool>",
    "close": "</tool>",
    "repeats": True,
    "content": "json",
    "transform": {"type": "function", "function": {"name": "{content.name}", "arguments": "{content.args}"}},
},

해석되지 않는 경로(여기서는 본문에 args가 없는 도구 호출)는 잘못된 도구 호출을 내보내는 대신 오류를 발생시켜요.

transform은 꽤 다재다능한데, 모델 출력이 표준 API와 매우 다른 형식일 때 필요해져요. GPT-OSS가 좋은 예시인데, 함수 이름을 JSON 본문이 아니라 채널 헤더에 넣기 때문이에요. 그래서 open_pattern의 이름 붙은 그룹으로 포착하고 transform 안에서 content와 병합해야 해요. open_pattern과 close_pattern의 모든 이름 붙은 그룹은 content와 함께 변수로 사용 가능해져요:

"tool_calls": {
    "open_pattern": r"<\|channel\|>commentary to=functions\.(?P<name>\w+).*?<\|message\|>",
    "close": "<|call|>",
    "repeats": True,
    "content": "json",
    "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
},

함수 이름이 JSON 본문이 아니라 채널 헤더에 있으므로, 이 입력:

<|channel|>commentary to=functions.get_current_weather <|constrain|>json<|message|>{"location": "San Francisco, CA"}<|call|>

이렇게 됩니다:

[{"type": "function", "function": {"name": "get_current_weather", "arguments": {"location": "San Francisco, CA"}}}]

때로는 필드의 파싱된 콘텐츠가 그 자체로 레코드 리스트이고 각각을 재구성하고 싶을 때가 있어요. Cohere 템플릿이 좋은 예시예요. 모든 도구 호출을 단일 JSON 배열 안에 내보내므로 transform_each: True를 설정해서 transform을 요소별로 적용해요. 각 배열 요소의 키가 템플릿 스코프로 풀리므로 "{tool_name}"은 현재 요소의 tool_name을 조회해요:

"tool_calls": {
    "open": "<|START_ACTION|>",
    "close": "<|END_ACTION|>",
    "content": "json",
    "transform_each": True,
    "transform": {"type": "function", "function": {"name": "{tool_name}", "arguments": "{parameters}"}},
},

이것은 이런 출력을:

[
    {"tool_name": "greet_user", "parameters": {"greeting": "Hi!"}},
    {"tool_name": "search", "parameters": {"query": "weather tomorrow"}}
]

우리 표준 API에 맞는 이런 출력으로 변환해요:

[
    {"type": "function", "function": {"name": "greet_user", "arguments": {"greeting": "Hi!"}}},
    {"type": "function", "function": {"name": "search", "arguments": {"query": "weather tomorrow"}}}
]

transform_each 플래그는 content가 이미 리스트일 때만 필요해요. 각 매치가 하나의 요소에 기여하고(repeats: True가 그것들을 축적하는) 더 흔한 경우에는 transform이 기본적으로 각 요소에 적용돼요.

프레임워크 개발자: Regex 이식성 (Framework developers: Regex portability)

open_pattern, close_pattern, start_anchor_pattern은 regex 문자열이에요. 대부분의 사용자, 그리고 대부분의 모델 작성자에게도 이는 문제가 되지 않아야 하지만, 다른 언어로 응답 파싱 구현을 작성하는 개발자라면 우리의 구현 세부사항을 알아야 해요. 이 섹션은 비-Python 채팅 템플릿링을 작동시키기 위해 전체 Jinja 파서를 구현해야 했던 모든 분들을 위한 거예요. 아래의 간단한 지침을 따르면 응답 템플릿이 훨씬 덜 고통스러울 거예요:

  • 채팅 파싱의 모든 regex에 Python의 regex 모듈을 사용해요. 모든 Python3 문자열은 unicode이므로 우리의 모든 regex 매치는 unicode 인식이에요. 특히 \w 같은 흔한 문자에 영향을 줘요. 엔진에서 관련 unicode 플래그를 설정해야 해요.
  • 모든 regex를 re.DOTALL 활성, re.MULTILINE 비활성으로 컴파일해서, .가 \n을 매치하지만 ^와 $는 줄바꿈이 아닌 전체 입력의 시작/끝에만 매치하게 해요.
  • 이름 붙은 그룹에 (?P<name>...) 구문을 사용해요. 다른 regex 구현은 이름 붙은 캡처 그룹 구문이 매우 다르므로, regex에서 이 패턴을 찾아 로컬 구현에 맞게 다시 써야 할 수도 있어요.
  • 데이터를 내보낼 때를 결정하기 위해 부분 regex 매칭(partial regex matching)을 사용해요. 영역의 끝이 여러 토큰으로 구성될 수 있고, 그 끝 구분자를 내보내고 싶지 않기 때문에, 토큰이 영역 안에 있고 경계의 일부가 아니라고 확신될 때까지 보류하지요. 이는 영역 끝 regex가 부분 매치를 가질 때마다 regex가 매치되거나 실패할 때까지 데이터를 보류한다는 뜻이에요. regex 엔진이 부분 매칭을 지원하지 않으면 여전히 응답 템플릿을 구현할 수 있지만, 이 문제에 대한 다른 해결책을 찾아야 할 수도 있어요. 간단한 방법 하나는 닫는 구분자를 확실히 볼 때까지 이 영역들을 보류하거나 dirty로 내보내는 거예요.
  • regex 엔진이 (?!...) 같은 lookaround를 지원하지 않을 수도 있어요(드물게). 응답 템플릿에서 흔히 쓰이진 않지만 나타날 수 있고 우리는 지원해요! 그런 경우 오류를 던지거나, regex 엔진이 가능한 매치를 찾았을 때 lookaround를 수동으로 추출해 코드에서 강제해야 할 수도 있어요.
  • backreference, atomic group, 소유 한정자(possessive quantifier), 재귀 같은 다른 고급 기능은 일반적으로 응답 템플릿에서 사용되지 않아요. 모델 작성자들이 이를 사용하지 않도록 권할 테니, 안전하게 무시해도 될 거예요.

더 알아보기 (Learn more)