Transformers documentation

Phân tích phản hồi

You are viewing main version, which requires installation from source. If you'd like regular pip install, checkout the latest stable version (v5.22.2).
Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

Phân tích phản hồi

Việc các mô hình chat tạo ra các đầu ra có cấu trúc, thay vì chỉ một chuỗi phản hồi duy nhất, ngày càng trở nên phổ biến. Ví dụ, một mô hình suy luận có thể phát ra một chuỗi suy nghĩ chứa dấu vết suy luận của nó, trong khi một mô hình gọi công cụ có thể phát ra tên hàm và các đối số.

Vấn đề với các đầu ra có cấu trúc là đầu ra của LLM vốn không mang tính cấu trúc. Các API của LLM thường nhận và trả về các dict tin nhắn, với các khóa như role, content và reasoning_content, nhưng bên trong, LLM thực chất chỉ tiếp tục một chuỗi token duy nhất. Chúng ta sử dụng một lớp kết nối (glue layer) để liên kết API hướng tới người dùng với luồng token thực tế của mô hình. Để chuyển đổi đầu vào thành một luồng token, chúng ta sử dụng chat_templates, được đề cập trong các tài liệu khác. Tài liệu này nói về nửa còn lại của lớp kết nối đó: Response templates, hệ thống để chuyển đổi các token do mô hình tạo ra thành một dict phản hồi có cấu trúc.

Nhiều mặt, response templates thực hiện thao tác ngược lại so với chat templates. Với chat templates, bạn đưa vào một danh sách các tin nhắn và nhận về các token sẵn sàng để đưa vào mô hình. Với response templates, bạn đưa vào các token đầu ra thô của mô hình và nhận về một tin nhắn có cấu trúc. Giống như chat templates, response templates cho phép người dùng bỏ qua các chi tiết phức tạp về định dạng cụ thể và các token điều khiển mà mô hình mong đợi, và sử dụng một API phổ quát của các dict tin nhắn hoạt động với bất kỳ mô hình nào.

Cách tốt nhất để hiểu các mẫu phản hồi là xem chúng hoạt động. Điểm vào chính là phương thức parse_response(), nhận một chuỗi đơn hoặc một lô:

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", "reasoning_content": "...", "content": "..."}

Khi một tokenizer có response_template, phương thức parse_response sẽ chuyển đổi một thông điệp đầu ra thành một dict có cấu trúc một cách sạch sẽ, sẵn sàng để thêm vào cuộc trò chuyện. Lưu ý rằng chúng ta cần truyền cả prefix (các token của prompt) vào phương thức này. Điều này là do nhiều chat template bắt đầu các thông điệp hoặc mở các khối suy nghĩ trước khi để mô hình bắt đầu phản hồi, và do đó trình phân tích của chúng ta cần nhìn thấy prompt để hiểu thông điệp. Toàn bộ phần prefix trước lượt cuối cùng sẽ bị loại bỏ; chúng ta chỉ phân tích một thông điệp tại một thời điểm. Chúng ta chỉ cần prefix để đảm bảo rằng chúng ta đang nhìn thấy toàn bộ thông điệp cuối cùng, và không bỏ sót bất kỳ trường nào đã được điền sẵn!

Vì một prefix bị thiếu có thể gây ra việc phân tích sai một cách âm thầm cho một tin nhắn đã được điền trước, prefix là bắt buộc. Việc bỏ qua nó sẽ gây ra một lỗi. Trong trường hợp hiếm hoi mà bạn chắc chắn rằng kết quả sinh đã chứa tin nhắn hoàn chỉnh và không cần ngữ cảnh prefix, hãy truyền prefix="" (hoặc một danh sách rỗng các token id) để từ chối một cách rõ ràng.

Nếu tokenizer không được đặt response template, parse_response sẽ gây ra lỗi. Chúng tôi đang nỗ lực thêm template cho nhiều model hơn với tốc độ nhanh nhất có thể!

Phân tích phản hồi dạng luồng

Trong ví dụ trên, chúng ta phân tích phản hồi của mô hình một lần duy nhất sau khi quá trình sinh hoàn tất. Tuy nhiên, trong nhiều trường hợp, chúng ta có thể muốn phân tích các thông điệp một phần ngay khi chúng được sinh ra, đặc biệt là trong các ứng dụng hướng đến người dùng, nơi chúng ta không muốn chỉ hiển thị một trang tĩnh trong một hoặc hai phút cho đến khi mô hình hoàn thành.

Khi bạn muốn phân tích cú pháp dạng streaming, hãy gọi tokenizer.get_response_parser(), hàm này trả về một ResponseParser. Tương tự như parse_response, hãy truyền chat prompt dưới dạng prefix= để bộ phân tích biết về các phần của tin nhắn đã được điền trước bởi chat template. Đối tượng được trả về là một bộ phân tích có trạng thái, bạn có thể cung cấp văn bản vào đó khi mô hình tạo ra chúng:

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)

Nếu yêu cầu bao gồm các công cụ, hãy truyền chúng qua tương tự (get_response_parser(..., tools=tools)). Các đối số của lệnh gọi công cụ sẽ được gán kiểu từ JSON Schema của công cụ gọi khi mỗi vùng đóng lại, do đó người dùng streaming sẽ thấy các đối số có kiểu schema tại region_close thay vì chỉ sau finalize(). Xem Gán kiểu cho các đối số của lệnh gọi công cụ để biết chi tiết.

Trình phân tích sẽ phát ra các sự kiện khi văn bản từ quá trình sinh được đưa vào. Điều này cho biết vùng nào đang được sinh. Khi vùng hoàn tất, nó sẽ được phát ra trong một sự kiện riêng với nội dung đã được phân tích đầy đủ. Vào cuối quá trình sinh, phương thức finalize() sẽ đẩy bất kỳ văn bản còn lại nào và phát ra các sự kiện cuối cùng, cũng như dict tin nhắn hoàn chỉnh.

Lưu ý rằng mặc dù parse_response có thể nhận các lô (batches), nhưng việc phân tích dạng luồng (streamed parsing) luôn xử lý từng chuỗi một: mỗi ResponseParser theo dõi trạng thái của một lần sinh (generation). Nếu bạn muốn phát luồng nhiều lần sinh cùng lúc, hãy tạo một ResponseParser cho mỗi chuỗi.

Sự kiện phát trực tiếp

Mỗi sự kiện phân tích được phát trực tiếp là một dict có khóa type. Có ba loại:

LoạiMô tảNội dung
region_openChỉ ra rằng mô hình đã bắt đầu một vùng mới, chẳng hạn như content hoặc reasoning_content.field (str): tên trường.
region_chunkMột đoạn văn bản cho khu vực hiện tại.field (str): tên trường. text (str): khối văn bản mới. dirty (bool): True nếu khối là văn bản thô cần phân tích.
region_closeChỉ ra rằng một vùng đã hoàn tất và khóa đó hiện đã được xác nhận.field (str): tên trường. value (any): giá trị đã được phân tích hoàn chỉnh cho vùng

Các sự kiện region_chunk được phát ra cho mọi region khi các byte đến, nên một giao diện streaming có thể hiển thị tiến độ ngay cả với các region có cấu trúc. Đối với các region dạng văn bản (text, int, float, bool), các chunk được đánh dấu dirty=False: mỗi chunk đã là một phần của giá trị cuối cùng (trừ khoảng trắng thừa bị cắt bỏ khi đóng). Đối với các region có cấu trúc, như các tool call định dạng JSON, các chunk được đánh dấu dirty=True. Điều này có nghĩa là văn bản là phần thân thô, chưa được phân tích; việc hiển thị tăng dần là an toàn, nhưng giá trị parsed (một dict, list, v.v.) chỉ đến trong sự kiện region_close tương ứng. Dù bằng cách nào, giá trị đã được xác nhận của một region luôn được mang bởi region_close, nên các bên tiêu dùng không quan tâm đến việc hiển thị trung gian có thể đơn giản bỏ qua các sự kiện region_chunk.

Nếu prefix của chat đã ghi bất kỳ nội dung nào vào tin nhắn (ví dụ: template mở một khối suy nghĩ, hoặc prefill của assistant bắt đầu một phản hồi trước khi chuyển giao cho model), parser sẽ phơi bày các sự kiện đó dưới dạng parser.initial_events, một danh sách mà bạn có thể phát lại vào trình render của mình trước khi cung cấp bất kỳ đầu ra nào từ model. Các vùng được mở và đóng bên trong prefix sẽ tạo ra một chuỗi đầy đủ region_open / region_chunk / region_close và giá trị đã phân tích của chúng sẽ nằm trong dict đầu ra, y hệt như thể chính model đã viết ra chúng.

Một luồng sự kiện điển hình có thể trông như sau:

{"type": "region_open",  "field": "reasoning_content"}
{"type": "region_chunk", "field": "reasoning_content", "text": "I should ", "dirty": False}
{"type": "region_chunk", "field": "reasoning_content", "text": "greet the user", "dirty": False}
{"type": "region_close", "field": "reasoning_content", "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!"}}}}

Lưu ý cách reasoning_content được phát ra với dirty=False, vì các trường như reasoning_content và content thường chỉ là văn bản thô. Điều này có nghĩa là bạn có thể coi các chunk là “đầu ra một phần” hợp lệ. Tuy nhiên, tool_calls được đánh dấu là dirty vì văn bản thô cần được làm sạch đáng kể - các tool call thường cần được phân tích cú pháp dưới dạng JSON hoặc một định dạng khác, sau đó được cấu trúc lại để tạo ra dict tool call cuối cùng. Kết quả là, đầu ra cuối cùng cho các vùng này thường trông rất, rất khác so với văn bản thô. Việc phân tích cú pháp cuối cùng này chỉ diễn ra khi region_close được đạt tới. Bạn tự quyết định muốn làm gì với các chunk dirty cho đến lúc đó - bạn có thể hiển thị chúng như vậy để cho người dùng thấy đầu ra “thô”, hoặc đơn giản là chờ cho đến khi có thứ gì đó sạch sẽ để hiển thị.

Phần này kết thúc hầu hết những gì bạn cần biết để sử dụng mẫu phản hồi. Phần còn lại của tài liệu này tập trung vào bộ máy phân tích bên trong và cách viết mẫu phản hồi. Phần này chủ yếu liên quan đến các nhà phát triển và tác giả mô hình. Hầu hết mọi người có thể an toàn dừng lại tại đây!

Nâng cao: Viết một mẫu phản hồi

Cách tốt nhất để hiểu cách viết một mẫu phản hồi là chọn một ví dụ cụ thể. Dưới đây là một phản hồi thô từ SmolLM có thể trông như thế nào:

<think>
I should greet the user
</think>

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

Khi chúng ta phân tích đầu ra này ở định dạng dict tin nhắn tiêu chuẩn, nó sẽ trông như sau:

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

Và đây là mẫu (template) để phân tích nó. Đừng lo lắng - phần lớn nội dung khá tự giải thích!

{
    "defaults": {"role": "assistant"},
    "start_anchor": "<|im_start|>assistant\n",
    "fields": {
        "reasoning_content": {"open": "<think>", "close": "</think>", "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",
        },
    },
}

Về cơ bản, template định nghĩa các trường và định giới. Mỗi trường tương ứng với một khóa trong dict đầu ra. Các trường cũng bao gồm thông tin để phân tích cú pháp cho văn bản bên trong định giới của chúng. Có một điểm tinh tế: trường content không có open, vì trong SmolLM (và một số mô hình khác), nó không được đánh dấu bằng một token đặc biệt. Thay vào đó, content được lưu trữ trong khoảng trống sau các vùng khác, nhưng trước khi kết thúc chuỗi. Trong template của chúng ta, chúng ta biểu diễn điều này như một trường ẩn / phần còn lại, thu thập bất kỳ văn bản nào không được gán cho vùng khác.

Tên trường nên khớp với các khóa mà chat template của model đọc, để một tin nhắn đã được phân tích có thể được thêm vào cuộc trò chuyện và được render lại. Cụ thể, đặt tên trường suy luận là reasoning_content. Đây là khóa chuẩn cho suy luận trong các dict tin nhắn, và là khóa mà hầu hết các chat template đọc suy luận từ đó.

Ngoài fields, template hỗ trợ hai khóa cấp cao nhất:

  • defaults (tùy chọn) — Một dict chứa các giá trị được điền sẵn trong kết quả đầu ra (ví dụ: {"role": "assistant"}). Các khóa ở đây luôn được giữ lại trong kết quả đã phân tích, ngay cả khi không có trường nào ghi vào chúng; các khóa khác sẽ bị loại bỏ nếu trường tương ứng không thu thập được gì.
  • start_anchor (str) / start_anchor_pattern (str regex) — Đánh dấu vị trí bắt đầu của thông điệp trợ lý hiện tại bên trong một chat prompt. Khi bạn truyền prefix= vào parse_response hoặc get_response_parser, trình phân tích sẽ cắt phần prefix từ phía phải vượt qua lần xuất hiện cuối cùng của anchor này trước khi xử lý, nhờ đó các lượt hội thoại trước trong một cuộc trò chuyện nhiều lượt sẽ không làm nhiễu trạng thái của thông điệp hiện tại. Anchor chỉ được áp dụng cho prefix, không bao giờ áp dụng cho response/generation mà bạn phân tích — một số định dạng hợp lệ sẽ phát lại anchor này ở giữa thông điệp (đầu ra harmony của gpt-oss mở mỗi kênh bằng <|start|>assistant), do đó việc cắt response vượt qua anchor sẽ làm mất đi phần suy luận và các cuộc gọi công cụ của chính mô hình. Đây là lý do tại sao chỉ riêng phần generation không bao giờ đủ để bảo vệ khỏi sự tràn lan lịch sử: hãy truyền prompt dưới dạng prefix=. Đối với các mô hình kiểu ChatML, anchor thường là "<|im_start|>assistant\n". Phải đặt đúng một trong start_anchor hoặc start_anchor_pattern.

Ví dụ, với phần tiền tố đa lượt sau (lưu ý hai lượt của trợ lý):

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

trình phân tích sẽ cắt bỏ mọi nội dung cho đến cuối cùng <|im_start|>assistant\n, loại bỏ lượt hội thoại "Hello!" trước đó. Quy tắc là mọi thứ ngoại trừ lượt hội thoại cuối cùng của trợ lý luôn bị loại bỏ.

Tương tự như chat template, response template được lưu dưới dạng thuộc tính của tokenizer và được lưu trữ cùng với tokenizer. Khác với chat template, chúng ta lưu chúng bên trong tokenizer_config.json chứ không phải trong một tệp riêng biệt, vì định dạng của chúng phù hợp tự nhiên với JSON, khác với kịch bản Jinja của chat template.

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

Nâng cao: Tham chiếu API trường

Mỗi trường hỗ trợ một số khóa. Chúng ta có thể chia chúng thành hai loại. Thứ nhất, đó là các khóa xác định cách trường nên được ghi nhận:

KhóaLoạiMục đích
openstr hoặc list[str]Chuỗi văn bản mở vùng này. Một danh sách các chuỗi có nghĩa là “khớp với bất kỳ chuỗi nào trong số này”.
open_patternstr (regex)Phương án thay thế bằng Regex cho open. Các nhóm được đặt tên sẽ trở thành các biến bắt (capture variables) có sẵn cho transform.
closestr hoặc list[str]Chuỗi văn bản (hoặc danh sách các chuỗi) đóng vùng này. Bỏ qua để chạy đến hết luồng.
close_patternstr (regex)Phương án thay thế bằng Regex cho close. Các nhóm được đặt tên sẽ trở thành các biến bắt (capture variables) có sẵn cho transform.
repeatsboolNếu là true, trường này là một danh sách và mỗi lần khớp sẽ thêm vào. Mặc định là false.
joinstrVới repeats, nối các kết quả khớp thành một chuỗi duy nhất bằng bộ phân tách này thay vì một danh sách.
optionalboolNếu là false và vùng không bao giờ khớp, chúng ta sẽ gây ra một lỗi. Mặc định là true.

Một trường phải có hoặc open hoặc open_pattern, nhưng không được có cả hai, và điều tương tự cũng đúng với close và close_pattern.

Một trường có thể bỏ qua hoàn toàn close/close_pattern, trong trường hợp đó vùng sẽ ở trạng thái mở cho đến hết văn bản được tạo ra. Điều này hữu ích cho một trường cuối cùng chạy đến hết thông điệp.

Trường có cả open lẫn open_pattern đều không có là trường ẩn: nó luôn hoạt động khi không có vùng nào được mở một cách tường minh, do đó nó bắt lại phần văn bản còn lại. Tối đa chỉ có một trường có thể là ẩn. Cách dùng này thường gặp nhất khi content không có các thẻ token đặc biệt, mà chỉ được viết dưới dạng văn bản thuần túy sau các trường khác.

Ngoài việc chỉ định các ký hiệu mở và đóng, bạn cũng có thể chỉ định repeats, cho biết trường đó là một danh sách và các ký hiệu có thể khớp nhiều lần. Điều này phổ biến nhất trong việc gọi công cụ song song, khi một mô hình phát ra nhiều lời gọi công cụ đồng thời:

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

Tuy nhiên, không phải trường lặp nào cũng nên trở thành một danh sách. Một số mô hình phát ra nhiều khối reasoning_content hoặc content trong một tin nhắn duy nhất, và đầu ra tự nhiên cho những trường hợp đó là một chuỗi được nối lại. Việc đặt join (một chuỗi phân cách, thường chỉ là "") sẽ chuyển một trường repeats từ việc thu thập một danh sách sang việc nối các phần khớp với nhau:

field = {"reasoning_content": {"open": "<think>", "close": "</think>", "repeats": True, "join": "\n"}}
input = "<think>first</think>...<think>second</think>"
# Returns: {"reasoning_content": "first\nsecond"}

Mỗi lần khớp của trường join phải được phân tích thành một chuỗi. Khi streaming, region_close vẫn mang giá trị riêng của từng khối; ký tự phân cách chỉ xuất hiện trong dict tin nhắn cuối cùng.

Cuối cùng, bạn có thể chỉ định optional: false cho các trường bắt buộc phải có. Nếu một trường như vậy bị thiếu, chúng tôi sẽ gây ra lỗi thay vì chỉ trả về một dict thông báo mà không có trường đó.

Việc kết thúc quá trình sinh sẽ đóng và hoàn tất mọi vùng đang mở, ngay cả khi chưa thấy ký tự đóng tương ứng.

Phân tích nội dung của một trường

Sau khi xác định cách bắt một trường, chúng ta cũng cần chỉ định cách phân tích văn bản thô bên trong phần bắt đó. Có bốn khóa điều khiển điều này:

KhóaLoạiMục đích
contentstrLoại nội dung bên trong vùng này. Mặc định là "text". Mỗi loại có trình phân tích riêng.
content_argsdictCác đối số cần được truyền cho trình phân tích nội dung của vùng này.
transformdict/listMẫu tùy chọn sau khi phân tích cú pháp để định dạng lại thân đã phân tích (xem Transform).
transform_eachboolNếu là true, nội dung đã phân tích phải là một danh sách và transform được áp dụng cho từng phần tử.

Chìa khóa đầu tiên (và quan trọng nhất) là content. Chìa khóa này chỉ ra kiểu nội dung của trường, quyết định trình phân tích cú pháp sẽ được sử dụng để chuyển đổi văn bản thô được thu thập trong trường thành kết quả đầu ra cuối cùng. content_args được dùng để cấu hình trình phân tích cú pháp, cho phép chúng ta hỗ trợ các đặc thù định dạng khác nhau mà không cần đến mã tùy chỉnh. Chúng ta sẽ lần lượt xem xét từng kiểu trình phân tích cú pháp và các đối số của chúng.

Các kiểu dữ liệu cơ bản

text, int, float và bool là các kiểu dữ liệu cơ bản. Các kiểu nội dung này chỉ loại bỏ khoảng trắng và sau đó thực hiện chuyển đổi kiểu đơn giản nếu cần. Chúng không có content_args, ngoại trừ text hỗ trợ tham số strip, dùng để loại bỏ khoảng trắng ở đầu và cuối văn bản được bắt, với giá trị mặc định là true.

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

json

Trình phân tích json phân tích văn bản đã bắt được dưới dạng JSON. Đây là công cụ chính cho các đối số của tool-call và bất kỳ thứ gì có cấu trúc lồng nhau. Nó chấp nhận một vài content_args tùy chọn để xử lý các cách khác nhau mà các model làm hỏng JSON trong thực tế:

  • unquoted_keys (bool, mặc định false): Bật khi tên khóa là dạng thô thay vì được đặt trong dấu nháy (ví dụ: {name: "foo"}). Hữu ích cho các mô hình xuất ra các hằng số đối tượng theo phong cách Javascript thay vì JSON nghiêm ngặt.
  • string_delims (danh sách các cặp [open, close], tùy chọn): dành cho các model bọc giá trị chuỗi trong các ký tự phân cách tùy chỉnh thay vì "...". Mỗi cặp cung cấp một ký hiệu mở và một ký hiệu đóng.
  • allow_non_json (bool, mặc định false): nếu việc phân tích thất bại, trả về văn bản thô đã được loại bỏ khoảng trắng thay vì gây ra ngoại lệ. Hữu ích như một phương án dự phòng cho các trường mà mô hình thường xuất ra JSON nhưng thỉnh thoảng lại chuyển sang văn bản thuần túy.

unquoted_keys và string_delims đều tồn tại để xử lý các mô hình tạo ra đầu ra không chuẩn, gần như nhưng không hoàn toàn là JSON, vì vậy bạn chỉ cần dùng chúng cho một số ít mô hình.

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

xml-inline

Trình phân tích xml-inline dành cho các vùng được tạo thành từ một chuỗi phẳng các thẻ kiểu XML, trong đó mỗi thẻ trở thành một mục trong dict. Nó thường được sử dụng nhất bên trong trường tool_calls cho các mô hình xuất mỗi đối số dưới dạng một thẻ riêng thay vì một khối JSON:

  • tag_pattern (str, bắt buộc): regex khớp với một tag duy nhất. Phải chứa các nhóm có tên key (khóa của dict kết quả) và value (văn bản thô trở thành giá trị của dict).
  • value_parser (dict, optional): trình phân tích nội dung lồng nhau được áp dụng cho mỗi value được bắt. Một dict có name (trình phân tích, ví dụ "json", "int") và tùy chọn args (các content_args của nó). Nếu bỏ qua, các giá trị sẽ giữ nguyên dạng chuỗi thô.
  • merge_duplicates (bool, mặc định false): khi cùng một khóa xuất hiện nhiều lần, thu thập các giá trị vào một danh sách thay vì để các khớp sau ghi đè lên các khớp trước.

Ví dụ, Qwen3 phát ra mỗi đối số của lệnh gọi công cụ dưới dạng thẻ <parameter> riêng, và chúng ta phân tích cú pháp như sau:

"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}"}},
}

Lưu ý value_parser lồng nhau: mỗi giá trị tham số đều được chạy qua trình phân tích json (với allow_non_json để các chuỗi thông thường vẫn đi qua). Nạp trường tool_calls ở trên vào đầu vào này:

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

Trình phân tích kv-lines xử lý các cặp key: value được phân tách theo dòng (tương tự metadata kiểu YAML hoặc các tệp .env). Mỗi dòng sẽ trở thành một mục trong dict kết quả. Tất cả các đối số đều là tùy chọn:

  • line_sep (str, mặc định "\n"): ký tự phân tách giữa các cặp.
  • kv_sep (str, mặc định ":"): ký tự phân tách giữa một key và giá trị của nó trong một dòng. Chỉ vị trí xuất hiện đầu tiên được dùng làm điểm tách, do đó giá trị có thể chứa ký tự phân tách.
  • strip (bool, mặc định true): loại bỏ khoảng trắng thừa ở hai đầu của mỗi key và value.
  • value_parser (dict, optional): trình phân tích nội dung lồng nhau được áp dụng cho từng giá trị, theo cùng định dạng {"name": ..., "args": ...} như với xml-inline. Nếu bị bỏ qua, các giá trị sẽ được giữ nguyên dưới dạng chuỗi thô.

Các dòng trống hoặc không chứa kv_sep sẽ bị bỏ qua một cách im lặng, do đó các dòng trống thừa trong vùng được ghi nhận sẽ được chấp nhận.

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

Lưu ý age giữ "30" dưới dạng chuỗi; thêm value_parser là {"name": "int"} để chuyển đổi nó thành 30.

Định kiểu các đối số của tool-call

Đôi khi, bộ phân tích phản hồi có thể phân tích đầu ra của mô hình với kiểu dữ liệu sai. Ví dụ, nó có thể phân tích số float 1.5 thành “1.50”. Điều này có thể gây ra vấn đề với việc gọi công cụ, nếu công cụ kỳ vọng một đối số ở một kiểu dữ liệu nhưng lại nhận được ở kiểu khác. Để tránh điều này, bạn có thể truyền tools của yêu cầu vào parse_response() hoặc ResponseParser để chuyển đổi chúng bằng cách sử dụng parameters của JSON Schema cho từng công cụ. Các công cụ được chấp nhận ở cùng định dạng như apply_chat_template(): JSON schemas, hoặc các hàm Python có type hints và docstrings, được tự động chuyển đổi thành schemas.

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"}

Biến đổi

Với hầu hết các trường, khóa transform là không cần thiết. Khóa này được sử dụng khi thân dữ liệu đã được phân tích cần được định dạng lại thành kết quả đầu ra cuối cùng, hoặc khi thông tin từ các ký tự phân tách phải được gộp vào kết quả. Khóa này thường xuất hiện nhất trong các trường tool_calls, vì những trường này thường có cấu trúc phức tạp.

transform là một template: một dict (hoặc list) mô tả hình dạng của kết quả đầu ra, trong đó bất kỳ chuỗi nào có dạng "{name}" sẽ được thay thế bằng giá trị tương ứng. Các giá trị có thể được truy cập từ content (thân đã được phân tích của vùng này) và bất kỳ nhóm có tên nào được bắt bởi open_pattern / close_pattern. Một trường hợp sử dụng rất phổ biến là bọc một dict gọi công cụ trong một dict bên ngoài có khóa function, vì đây là một phần của định dạng gọi công cụ chuẩn của chúng tôi:

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

Vì vậy, đầu vào thô này:

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

trở thành (lưu ý repeats: True khiến tool_calls trở thành một danh sách):

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

Một placeholder cho toàn bộ chuỗi như "{content}" sẽ trả về giá trị tra cứu được với kiểu dữ liệu được giữ nguyên — do đó, ở trên, dict JSON đã được phân tích sẽ được chèn trực tiếp làm giá trị của function. Placeholder phải là toàn bộ chuỗi: việc trộn lẫn văn bản và placeholder ("abc {name} def") không được phép. Chúng không phải là f-strings!

Placeholder cũng có thể nhận một đường dẫn có dấu chấm như "{content.args}", trong đó hệ thống sẽ tìm content rồi đi xuống khóa args của dict đã được phân tích. Đây là cách bạn định hình lại thân của tool-call khi tên khóa không khớp với định dạng chuẩn của chúng tôi. Ví dụ, một model phát ra {"name": ..., "args": ...} cần đổi tên args thành arguments:

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

Một đường dẫn không thể phân giải (ở đây, một lời gọi công cụ có thân thiếu args) sẽ gây ra lỗi thay vì phát ra một lời gọi công cụ không hợp lệ.

transform khá đa năng, điều này trở nên cần thiết khi đầu ra của mô hình có định dạng khác biệt hoàn toàn so với API chuẩn của chúng ta. GPT-OSS là một ví dụ điển hình - nó nhúng tên hàm vào tiêu đề kênh thay vì trong thân JSON, do đó chúng ta phải bắt nó bằng một nhóm có tên trong open_pattern và gộp nó với content bên trong transform. Tất cả các nhóm có tên trong open_pattern và close_pattern sẽ trở thành các biến có sẵn cùng với 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}"}},
},

Tên hàm nằm trong phần tiêu đề kênh, không phải trong thân JSON, vì vậy:

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

trở thành:

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

Đôi khi nội dung đã phân tích của một trường bản thân là một danh sách các bản ghi và bạn muốn định dạng lại từng bản ghi. Mẫu Cohere là một ví dụ tốt: nó phát ra tất cả các cuộc gọi công cụ bên trong một mảng JSON duy nhất, vì vậy chúng ta đặt transform_each: True để áp dụng phép biến đổi cho từng phần tử. Các khóa của từng phần tử mảng được giải nén vào phạm vi của mẫu, do đó "{tool_name}" sẽ tra cứu tool_name trong phần tử hiện tại:

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

Điều này sẽ chuyển đổi một đầu ra như sau:

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

Thành một đầu ra như sau, phù hợp với API chuẩn của chúng ta:

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

Cờ transform_each chỉ cần thiết khi content đã là một danh sách; đối với trường hợp phổ biến hơn, trong đó mỗi phần khớp đóng góp một phần tử (và repeats: True tích lũy chúng), thì phép biến đổi sẽ được áp dụng cho từng phần tử theo mặc định.

Nhà phát triển framework: Khả năng tương thích của Regex

open_pattern, close_pattern và start_anchor_pattern là các chuỗi regex. Đối với hầu hết người dùng, và thậm chí cả với hầu hết tác giả mô hình, điều này không nên là vấn đề, nhưng nếu bạn là một nhà phát triển đang viết một phần thực hiện phân tích phản hồi bằng một ngôn ngữ khác, bạn nên nắm rõ các chi tiết thực hiện của chúng tôi. Phần này dành cho tất cả những ai đã phải triển khai một trình phân tích Jinja hoàn chỉnh để khiến chat templating không dùng Python hoạt động - chúng tôi hy vọng rằng nếu bạn làm theo các hướng dẫn đơn giản dưới đây, thì các mẫu phản hồi sẽ bớt đau đầu hơn nhiều:

  • Chúng tôi sử dụng module regex của Python cho tất cả các biểu thức chính tắc được dùng trong phân tích chat. Vì tất cả các chuỗi trong Python3 đều là unicode, mọi kết quả khớp biểu thức chính tắc của chúng tôi đều nhận diện unicode. Điều này đặc biệt ảnh hưởng đến các ký tự phổ biến như \w. Hãy đảm bảo bạn đặt các cờ unicode liên quan trong engine của mình.
  • Chúng tôi biên dịch tất cả các biểu thức chính tắc với re.DOTALL được bật và re.MULTILINE bị tắt, do đó . khớp với \n nhưng ^ và $ chỉ khớp với đầu/cuối của toàn bộ đầu vào, không phải các ký tự xuống dòng.
  • Chúng tôi sử dụng cú pháp (?P<name>...) cho các nhóm có tên. Các triển khai regex khác có cú pháp nhóm bắt có tên rất khác nhau, vì vậy bạn có thể cần tìm kiếm mẫu này trong các regex và viết lại để khớp với triển khai cục bộ của bạn.
  • Chúng tôi sử dụng khớp regex một phần để quyết định khi nào có thể phát dữ liệu. Điều này là cần thiết vì cuối của một vùng có thể bao gồm nhiều token, và chúng tôi không muốn phát bộ phân tách cuối vùng đó, do đó chúng tôi giữ lại các token cho đến khi chắc chắn rằng chúng nằm bên trong vùng và không phải là một phần của ranh giới. Điều này có nghĩa là bất cứ khi nào regex kết thúc vùng có một phần khớp, chúng tôi sẽ giữ lại dữ liệu cho đến khi regex khớp hoặc thất bại. Nếu engine regex của bạn không hỗ trợ khớp một phần, bạn vẫn có thể triển khai các mẫu phản hồi, nhưng có thể cần tìm một giải pháp khác cho vấn đề này. Một cách tiếp cận đơn giản là chỉ giữ lại các vùng này / phát chúng dưới dạng dirty cho đến khi bạn nhìn thấy rõ ràng bộ phân tách kết thúc.
  • Engine regex của bạn có thể (hiếm khi) không hỗ trợ các lookaround như (?!...). Mặc dù chúng không thường xuyên được sử dụng trong các mẫu phản hồi, nhưng chúng có thể xuất hiện và chúng tôi có hỗ trợ chúng! Bạn có thể cần phải ném ra một lỗi trong những trường hợp đó, hoặc tự động trích xuất các lookaround và thực thi chúng trong mã của bạn khi engine regex tìm thấy một khớp có thể.
  • Các tính năng nâng cao khác như backreferences, atomic groups, possessive quantifiers, recursion và tương tự thường không được sử dụng trong các mẫu phản hồi. Chúng tôi sẽ cố gắng thuyết phục các tác giả mô hình không sử dụng chúng, vì vậy bạn có thể an tâm bỏ qua chúng.
Update on GitHub