vLLM 本地大語言模型工具調用失準的排查與修正方法
Your Local LLM Isn't Bad at Tool Calling, Your Pipeline Is: Fixing Tool-Call Accuracy in vLLM
作者以 Qwen2.5-7B-Instruct 配合 vLLM,自行託管內部 AI 智能體並修正工具調用 pipeline,準確率由約 70% 升至 97.5%,無需更換模型。
Tuần trước team mình chuyển một agent nội bộ từ API thương mại sang self-host bằng vLLM với Qwen2.5-7B-Instruct. Benchmark chat thì ổn, nhưng vừa chạy production thì tool calling hỏng liên tục: model trả về JSON thiếu field, gọi tool không tồn tại, hoặc nhét tool call vào giữa đoạn text làm parser không nhận ra. Accuracy đo được chỉ khoảng 70%. Sau hai ngày debug, mình nhận ra phần lớn lỗi không nằm ở model mà ở cách mình cấu hình server và xử lý output. Sau khi sửa pipeline, con số lên trên 97% mà không cần đổi model. Bài này chia sẻ lại những gì mình đã làm.
Tool call trong vLLM thực sự hoạt động thế nào
Nhiều người nghĩ khi gửi tools=[...] qua OpenAI-compatible API thì server "tự hiểu" tool calling. Thực tế, model chỉ sinh ra text. vLLM phải dùng một tool call parser để tách phần text đó thành tool_calls có cấu trúc. Mỗi họ model có format riêng: Qwen và Hermes dùng tag <tool_call>...</tool_call>, Llama 3.x dùng format JSON riêng, Mistral dùng token [TOOL_CALLS].
flowchart LR
A[Client gửi messages + tools] --> B[Chat template render tools vào prompt]
B --> C[Model sinh text]
C --> D{tool_choice?}
D -->|auto| E[Tool parser tách text bằng regex/tag]
D -->|required / named| F[Guided decoding theo JSON schema]
E --> G[Response tool_calls]
F --> GĐiểm mấu chốt nằm ở nhánh D. Với tool_choice="auto", model sinh text tự do và parser phải "đoán" phần nào là tool call. Chỉ cần model viết thêm một câu dẫn hoặc quên đóng tag là parse fail, và client nhận về một message content bình thường thay vì tool_calls. Với tool_choice="required" hoặc chỉ định tên function cụ thể, các bản vLLM gần đây (0.8+) dùng structured outputs / guided decoding: ở mỗi bước sinh token, những token vi phạm JSON schema bị mask đi. Model không thể sinh JSON sai format dù có muốn.
Bước 1: Cấu hình server đúng
Lỗi đầu tiên của mình rất ngớ ngẩn: dùng sai parser. Qwen2.5 phải dùng hermes, không phải llama3_json. Dùng sai thì parser không bao giờ match, mọi tool call đều rơi vào content.
# vLLM 0.9.x, Qwen2.5-7B-Instruct
vllm serve Qwen/Qwen2.5-7B-Instruct \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--max-model-len 16384 \
--gpu-memory-utilization 0.90 \
--port 8000
# Llama 3.1/3.2: --tool-call-parser llama3_json
# Mistral: --tool-call-parser mistral
Vài lưu ý thực tế:
-
Chat template quan trọng không kém parser. Nếu model của bạn là bản fine-tune, kiểm tra xem
tokenizer_config.jsoncó rendertoolsvào prompt không. Không có thì model không hề biết tool tồn tại. Có thể truyền template riêng bằng--chat-template. -
Temperature thấp. Với tool call, mình để
temperature=0hoặc tối đa0.2. Sampling nóng làm tên field bị "sáng tạo" rất nhanh. - Schema ngắn gọn. Description dài, nhiều tool na ná nhau làm model nhỏ bị rối. Mình gộp 14 tool xuống còn 8 và accuracy tăng thấy rõ.
Bước 2: Tách quyết định "có gọi tool không" và "gọi thế nào"
Đây là thay đổi tạo ra nhiều cải thiện nhất. Thay vì để auto gánh hết, mình tách thành hai bước: bước một hỏi model có cần tool không (hoặc dùng router đơn giản), bước hai nếu cần thì gọi với tool_choice="required" để guided decoding đảm bảo JSON hợp lệ. Sau đó vẫn validate lại bằng Pydantic, vì guided decoding bảo đảm cú pháp chứ không bảo đảm ngữ nghĩa (ví dụ ngày 2026-02-30 vẫn đúng kiểu string).
# pip install openai==1.54.0 pydantic==2.9.2
import json
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError, field_validator
from datetime import date
client = OpenAI(base_url="http://localhost:8000/v1", api_key="none")
MODEL = "Qwen/Qwen2.5-7B-Instruct"
class CreateInvoice(BaseModel):
customer_id: str = Field(pattern=r"^CUS-\d{6}$")
amount_vnd: int = Field(gt=0)
due_date: date
@field_validator("due_date")
@classmethod
def not_in_past(cls, v: date) -> date:
if v < date.today():
raise ValueError("due_date không được ở quá khứ")
return v
TOOLS = [{
"type": "function",
"function": {
"name": "create_invoice",
"description": "Tạo hoá đơn cho khách hàng",
"parameters": CreateInvoice.model_json_schema(),
},
}]
VALIDATORS = {"create_invoice": CreateInvoice}
def call_tool(messages: list[dict], max_retries: int = 2):
for attempt in range(max_retries + 1):
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
tool_choice="required", # bật guided decoding
temperature=0,
)
msg = resp.choices[0].message
call = msg.tool_calls[0]
validator = VALIDATORS.get(call.function.name)
if validator is None:
err = f"Tool '{call.function.name}' không tồn tại"
else:
try:
args = validator.model_validate_json(call.function.arguments)
return call.function.name, args
except ValidationError as e:
err = e.json(include_url=False)
# Phản hồi lỗi cụ thể cho model để nó tự sửa
messages = messages + [
msg.model_dump(exclude_none=True),
{"role": "tool", "tool_call_id": call.id,
"content": f"VALIDATION_ERROR: {err}. Hãy gọi lại tool với tham số đúng."},
]
raise RuntimeError("Tool call vẫn sai sau khi retry")
Vòng retry có feedback lỗi cụ thể là thứ rẻ nhất mà hiệu quả nhất. Model 7B rất hay sai pattern ID ở lần đầu nhưng sửa đúng ngay khi được chỉ ra field nào sai. Giới hạn 2 lần retry để không đốt GPU vô ích; quá số lần thì đẩy sang con người xử lý, đừng để agent đoán bừa.
Bước 3: Đo accuracy, đừng đo cảm giác
Trước đây mình đánh giá bằng cách "thử vài câu thấy ổn". Đó là lý do lỗi lọt ra production. Giờ mình giữ một file evals.jsonl khoảng 200 case lấy từ log thật, mỗi case có input và tool call kỳ vọng, rồi chạy mỗi lần đổi model, template hay prompt.
sequenceDiagram
participant E as Eval script
participant V as vLLM server
participant R as Report
E->>V: Gửi case (messages + tools)
V-->>E: tool_calls
E->>E: So sánh name + args với expected
E->>R: Ghi pass/fail theo loại lỗiimport json
from collections import Counter
def run_eval(path: str = "evals.jsonl"):
stats = Counter()
for line in open(path, encoding="utf-8"):
case = json.loads(line)
try:
name, args = call_tool(case["messages"])
except Exception:
stats["hard_fail"] += 1
continue
if name != case["expected_name"]:
stats["wrong_tool"] += 1
elif args.model_dump(mode="json") != case["expected_args"]:
stats["wrong_args"] += 1
else:
stats["pass"] += 1
total = sum(stats.values())
for k, v in stats.most_common():
print(f"{k:12} {v:4} ({v / total:.1%})")
if __name__ == "__main__":
run_eval()
Phân loại lỗi (wrong_tool, wrong_args, hard_fail) quan trọng hơn con số tổng. Nếu wrong_tool cao, vấn đề nằm ở description của tool hoặc có quá nhiều tool trùng ý. Nếu wrong_args cao, xem lại schema và ví dụ trong system prompt. Nếu hard_fail cao, gần như chắc chắn là sai parser hoặc chat template.
Kết quả của mình qua từng bước: sai parser ~70%, đúng parser + auto ~86%, chuyển sang required + Pydantic ~93%, thêm retry có feedback ~97.5%.
Kết luận
Tool calling với model self-host không phải "cắm vào là chạy". Trước khi kết luận model nhỏ quá kém và nâng lên 70B, hãy kiểm tra pipeline:
-
Kiểm tra đúng
--tool-call-parservà chat template cho họ model đang dùng. Đây là nguyên nhân số một gâyhard_fail. -
Dùng
tool_choice="required"hoặc named function khi đã biết chắc cần gọi tool, để tận dụng guided decoding thay vì để parser đoán. - Validate ngữ nghĩa bằng Pydantic, vì JSON đúng cú pháp chưa chắc đúng nghiệp vụ.
- Retry có feedback lỗi cụ thể, giới hạn 1-2 lần, rồi chuyển cho người xử lý thay vì để model đoán.
- Giữ một bộ eval từ log thật và phân loại lỗi, chạy lại mỗi khi đổi model hoặc prompt.
Làm năm việc này mất khoảng một buổi chiều, nhưng giúp bạn tiết kiệm được cả tháng tiền GPU cho một model lớn hơn mà có thể bạn chưa cần đến.
來源:DEV Community · dev.to