Files

6.6 KiB
Raw Permalink Blame History

Gateway-Level Tool Use v2

Техника реализации function calling на уровне гейтвея, когда upstream API не поддерживает нативный tool calling.

Суть

Модель получает определения тулзов через структурированный системный промпт, а не через нативный tools параметр API. Гейтвей парсит ответ и возвращает OpenAI-совместимый tool_calls формат.

Промпт-инжект v2

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

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-логика

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-ответ

Нестрим

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