AI 工具

用 GroqCloud Function Calling 实现受控工具调用循环

Groq 支持 OpenAI 风格的 tools / toolcalls。最小可用循环是:发消息 → 若返回 toolcalls 则本地执行 → 以 role=tool 回灌 → 再要最终回答。 这篇做天气查询演示(数据写死在字典里)和 mock 工具调用两轮对话。

BINBHO · 图文教程

Groq 支持 OpenAI 风格的 tools / tool_calls。最小可用循环是:发消息 → 若返回 tool_calls 则本地执行 → 以 role=tool 回灌 → 再要最终回答。 这篇做天气查询演示(数据写死在字典里)和 mock 工具调用两轮对话。

先看清成品

  • mock 代理跑通:用户问上海天气 → 调用 get_weather → 最终回答含 24/多云。
  • 2 项 pytest 通过。

合规提醒:使用自己的 GroqCloud 账号与 API Key;语音内容需你有权处理。模型与价格以 groq.com/pricing 与控制台为准,本文不写价格。遵守各模型许可。

运行说明:没有可用的 Groq 密钥。真实运行过:流式聊天 / Whisper / tool-calling 客户端对本地 mock 的请求,以及对 https://api.groq.com 使用无效密钥得到 HTTP 401(2026-10-23,UTC+8)。未上传真实用户音频,未产生计费。

做完之后你会得到什么?

成果 说明
tool_agent.py 最小 tool loop
mock_tools.py 两阶段 mock
test_94c.py 2 项测试

需要准备哪些环境?

项目 说明
Python 3.11+ 标准库;pip install pytest
Groq Key(可选) https://console.groq.com → API Keys
音频样例(B) 小于 100MB,官方格式之一

关键事实

事实 来源
请求可带 tools 与 tool_choice;助手消息可含 tool_calls 官方 Tool Use
工具结果消息:role=tool,tool_call_id 对应调用 id 同上
端点同为 /openai/v1/chat/completions 官方 OpenAI Compatibility

文件结构

94-groqcloud-C-src/
├── tool_agent.py
├── mock_tools.py
└── test_94c.py

第 1 步:Agent

#!/usr/bin/env python3
"""Minimal Groq tool-calling loop (official tools / tool_calls / role=tool)."""
from __future__ import annotations

import argparse
import json
import os
import sys
import urllib.error
import urllib.request


WEATHER = {
    "上海": {"temp": 24, "condition": "多云"},
    "北京": {"temp": 18, "condition": "晴"},
}

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询城市当前天气(演示数据)",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {"type": "string", "description": "城市名"},
                    "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
                },
                "required": ["location"],
            },
        },
    }
]


def get_weather(location: str, unit: str = "celsius") -> dict:
    info = WEATHER.get(location) or {"temp": None, "condition": "未知"}
    temp = info["temp"]
    if temp is not None and unit == "fahrenheit":
        temp = round(temp * 9 / 5 + 32, 1)
    return {"location": location, "temperature": temp, "unit": unit, "condition": info["condition"]}


DISPATCH = {"get_weather": lambda **kw: get_weather(**kw)}


def _post(base_url, api_key, payload):
    url = f"{base_url.rstrip('/')}/openai/v1/chat/completions"
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode(),
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        raise SystemExit(f"HTTP {e.code}: {e.read().decode(errors='replace')}") from e


def run_agent(*, base_url, api_key, model, user_text, max_rounds=3) -> str:
    messages = [
        {"role": "system", "content": "你是天气助手。需要实时天气时调用 get_weather。"},
        {"role": "user", "content": user_text},
    ]
    for _ in range(max_rounds):
        resp = _post(base_url, api_key, {"model": model, "messages": messages, "tools": TOOLS})
        msg = resp["choices"][0]["message"]
        tool_calls = msg.get("tool_calls") or []
        if not tool_calls:
            return msg.get("content") or ""
        messages.append(msg)
        for tc in tool_calls:
            fn = tc["function"]["name"]
            args = json.loads(tc["function"]["arguments"] or "{}")
            if fn not in DISPATCH:
                result = {"error": f"unknown tool {fn}"}
            else:
                result = DISPATCH[fn](**args)
            messages.append({
                "role": "tool",
                "tool_call_id": tc["id"],
                "name": fn,
                "content": json.dumps(result, ensure_ascii=False),
            })
    return "超过最大轮数"


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("question", nargs="?", default="上海现在天气怎么样?")
    p.add_argument("--base", default=os.environ.get("GROQ_BASE_URL", "https://api.groq.com"))
    p.add_argument("--key", default=os.environ.get("GROQ_API_KEY", ""))
    p.add_argument("--model", default="llama-3.1-8b-instant")
    args = p.parse_args(argv)
    if not args.key:
        print("设置 GROQ_API_KEY", file=sys.stderr)
        return 2
    print(run_agent(base_url=args.base, api_key=args.key, model=args.model, user_text=args.question))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

第 2 步:Mock

#!/usr/bin/env python3
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

API_KEY = "gsk-test"
STATE = {"calls": 0}


class H(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def do_POST(self):
        if self.path != "/openai/v1/chat/completions":
            self.send_error(404); return
        if self.headers.get("Authorization") != f"Bearer {API_KEY}":
            self.send_response(401); self.end_headers(); return
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        messages = body["messages"]
        tools = body.get("tools") or []
        # If last message is tool result, finish
        if messages and messages[-1].get("role") == "tool":
            content = f"根据工具结果:{messages[-1].get('content')}"
            resp = {"choices": [{"message": {"role": "assistant", "content": content}, "finish_reason": "stop"}]}
        elif tools and STATE["calls"] == 0:
            STATE["calls"] += 1
            args = json.dumps({"location": "上海", "unit": "celsius"}, ensure_ascii=False)
            resp = {"choices": [{"message": {
                "role": "assistant",
                "content": None,
                "tool_calls": [{"id": "call_1", "type": "function",
                                "function": {"name": "get_weather", "arguments": args}}],
            }, "finish_reason": "tool_calls"}]}
        else:
            resp = {"choices": [{"message": {"role": "assistant", "content": "不用工具也能聊"}, "finish_reason": "stop"}]}
        self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers()
        self.wfile.write(json.dumps(resp, ensure_ascii=False).encode())


if __name__ == "__main__":
    import os
    HTTPServer(("127.0.0.1", int(os.environ.get("PORT", "3097"))), H).serve_forever()

第 3 步:测试

import threading
from http.server import HTTPServer
import mock_tools
from tool_agent import run_agent, get_weather

def serve():
    mock_tools.STATE["calls"] = 0
    httpd = HTTPServer(("127.0.0.1", 0), mock_tools.H)
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    return httpd, httpd.server_address[1]

def test_weather_fn():
    r = get_weather("上海")
    assert r["temperature"] == 24

def test_agent_loop():
    httpd, port = serve()
    try:
        out = run_agent(base_url=f"http://127.0.0.1:{port}", api_key=mock_tools.API_KEY,
                        model="llama-3.1-8b-instant", user_text="上海天气")
        assert "24" in out or "多云" in out or "工具结果" in out
    finally:
        httpd.shutdown()

第 4 步:运行

# 2026-10-23 12:37 UTC+8
根据工具结果:{"location": "上海", "temperature": 24, "unit": "celsius", "condition": "多云"}

$ python -m pytest -q
2 passed

怎么检查结果?

  • 最终自然语言回答应含工具结果中的温度或状况。
  • DISPATCH 白名单:不要 eval 任意函数名。

调试示例

只信任白名单函数名。 tool_calls[].function.name 来自模型,必须在 DISPATCH 内查找;未知名称直接报错,避免模型“调用”到危险函数。

常见问题

现象 原因 处理
无限循环 模型反复 call 设 max_rounds
参数 JSON 坏 模型输出不合法 捕获 JSONDecodeError 并重试
真实天气 演示是假数据 换成你有权访问的 API

模板规则

  • 工具白名单。
  • 限制回合数。
  • 工具副作用要可审计。

项目资源

用途 地址
Tool Use(官方) https://console.groq.com/docs/tool-use/overview
Text Chat(官方) https://console.groq.com/docs/text-chat
Pricing https://groq.com/pricing

工具调用的安全边界在你的 DISPATCH,不在模型保证里。