191 lines
6.6 KiB
Markdown
191 lines
6.6 KiB
Markdown
# Gateway-Level Tool Use v2
|
||
|
||
Техника реализации function calling на уровне гейтвея, когда upstream API не поддерживает нативный tool calling.
|
||
|
||
## Суть
|
||
|
||
Модель получает определения тулзов через структурированный системный промпт, а не через нативный `tools` параметр API. Гейтвей парсит ответ и возвращает OpenAI-совместимый `tool_calls` формат.
|
||
|
||
## Промпт-инжект v2
|
||
|
||
```python
|
||
def build_tool_prompt(tools: list, tool_choice) -> str:
|
||
tool_defs = []
|
||
for i, tool in enumerate(tools):
|
||
t = tool.get("function", tool)
|
||
params = json.dumps(t.get("parameters", {}), ensure_ascii=False)
|
||
tool_defs.append(
|
||
f"### {t['name']}\n"
|
||
f"- Description: {t.get('description', 'No description')}\n"
|
||
f"- Parameters: `{params}`\n"
|
||
f"- Call ID: `call_{t['name']}_{i}`\n"
|
||
)
|
||
|
||
# Forced tool choice
|
||
forced_tool = None
|
||
if isinstance(tool_choice, dict):
|
||
forced_tool = tool_choice.get("function", {}).get("name")
|
||
elif tool_choice == "required":
|
||
forced_tool = "__required__"
|
||
|
||
force_line = ""
|
||
if forced_tool and forced_tool != "__required__":
|
||
force_line = f"\n**CRITICAL: You MUST call `{forced_tool}`. No text — only JSON.**\n"
|
||
elif forced_tool == "__required__":
|
||
force_line = "\n**CRITICAL: You MUST call one of the functions. No text — only JSON.**\n"
|
||
|
||
return (
|
||
"# SYSTEM: Function Calling Mode\n\n"
|
||
"You are a function-calling assistant. Available functions:\n\n"
|
||
+ "\n".join(tool_defs)
|
||
+ "\n\n## Response Format\n"
|
||
"```json\n"
|
||
"{\n"
|
||
' "tool_calls": [\n'
|
||
' {\n'
|
||
' "id": "call_<name>_<N>",\n'
|
||
' "type": "function",\n'
|
||
' "function": {\n'
|
||
' "name": "<name>",\n'
|
||
' "arguments": "<JSON-string>"\n'
|
||
' }\n'
|
||
' }\n'
|
||
' ]\n'
|
||
'}\n'
|
||
"```\n\n"
|
||
"## Rules\n"
|
||
"- If the user's request requires a function, respond with ONLY the JSON above — no other text.\n"
|
||
"- The `arguments` field MUST be a valid JSON string (double-escaped).\n"
|
||
"- Use correct JSON syntax — no trailing commas, no comments.\n"
|
||
+ force_line
|
||
+ "\n## User Request\n"
|
||
)
|
||
```
|
||
|
||
## Трёхуровневый парсинг v2
|
||
|
||
```python
|
||
def parse_tool_calls(text: str) -> list | None:
|
||
"""Level 1: direct JSON parse"""
|
||
try:
|
||
data = json.loads(text.strip())
|
||
if "tool_calls" in data:
|
||
return _normalize(data["tool_calls"])
|
||
except: pass
|
||
|
||
"""Level 2: JSON in markdown code blocks"""
|
||
for pat in [
|
||
r'```json\s*(\{[\s\S]*?"tool_calls"[\s\S]*?\})\s*```',
|
||
r'```\s*(\{[\s\S]*?"tool_calls"[\s\S]*?\})\s*```',
|
||
]:
|
||
m = re.search(pat, text)
|
||
if m:
|
||
try:
|
||
data = json.loads(m.group(1))
|
||
if "tool_calls" in data:
|
||
return _normalize(data["tool_calls"])
|
||
except: continue
|
||
|
||
"""Level 3: regex fallback — any JSON with tool_calls"""
|
||
for m in re.finditer(r'\{[\s\S]*?"tool_calls"[\s\S]*?\}', text):
|
||
try:
|
||
data = json.loads(m.group(0))
|
||
if "tool_calls" in data:
|
||
return _normalize(data["tool_calls"])
|
||
except: continue
|
||
|
||
return None
|
||
|
||
def _normalize(tool_calls):
|
||
"""Validate and normalize: arguments must be valid JSON string."""
|
||
for tc in tool_calls:
|
||
fn = tc.get("function", {})
|
||
args = fn.get("arguments", "{}")
|
||
if isinstance(args, dict):
|
||
args = json.dumps(args, ensure_ascii=False)
|
||
elif isinstance(args, str):
|
||
json.loads(args) # validate
|
||
fn["arguments"] = args
|
||
return tool_calls
|
||
```
|
||
|
||
## Retry-логика
|
||
|
||
```python
|
||
TOOL_RETRY_MAX = 2
|
||
|
||
for attempt in range(TOOL_RETRY_MAX + 1):
|
||
# ... execute chat ...
|
||
tool_calls = parse_tool_calls(full_text)
|
||
|
||
requires_tool = (
|
||
isinstance(tool_choice, dict) or
|
||
tool_choice == "required"
|
||
)
|
||
if requires_tool and not tool_calls and attempt < TOOL_RETRY_MAX:
|
||
# Усиливаем промпт
|
||
user_content += (
|
||
"\n\n**ERROR: You did not call the required function. "
|
||
"Respond with ONLY the function call JSON. No other text.**"
|
||
)
|
||
continue
|
||
break
|
||
```
|
||
|
||
## OpenAI-ответ
|
||
|
||
### Нестрим
|
||
|
||
```json
|
||
{
|
||
"choices": [{
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": null,
|
||
"tool_calls": [{
|
||
"id": "call_get_weather_0",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_weather",
|
||
"arguments": "{\"city\":\"London\"}"
|
||
}
|
||
}]
|
||
},
|
||
"finish_reason": "tool_calls"
|
||
}]
|
||
}
|
||
```
|
||
|
||
### Стрим
|
||
|
||
```
|
||
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_get_weather_0","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}
|
||
|
||
data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
|
||
|
||
data: [DONE]
|
||
```
|
||
|
||
## tool_choice — полная поддержка
|
||
|
||
| Значение | Поведение |
|
||
|----------|-----------|
|
||
| `"auto"` (default) | Инжектить тулзы, модель сама решает |
|
||
| `"none"` | Не инжектить тулзы, модель отвечает текстом |
|
||
| `"required"` | Форсировать вызов любого тула + retry |
|
||
| `{type:"function", function:{name:"X"}}` | Форсировать конкретный тул + retry |
|
||
|
||
## Ограничения
|
||
|
||
- **Слабые модели** (GPT-5 Nano, мелкие Llama) могут не следовать формату. Retry помогает но не гарантирует.
|
||
- **Стриминг** — tool_calls не парсятся на лету, клиент должен накопить полный ответ.
|
||
- **Multi-turn** — `tool` role сообщения + `tool_call_id` не пробрасываются (одноразовые сессии).
|
||
- **Parallel tool calls** — теоретически поддерживается форматом, но модель редко выдаёт больше одного.
|
||
|
||
## Рекомендуемые модели
|
||
|
||
Для надёжного tool use через промпт-инжект:
|
||
- **DeepSeek V3.2** — "agentic tool-use performance"
|
||
- **Mistral Small 3.2 24B** — "improved function calling"
|
||
- **Inkling (Thinking Machines)** — "tool-use systems"
|
||
- **Command R7B (Cohere)** — "tool use, agents" |