让大模型长出手脚,自己写SQL查数据库(Function Calling初探)

让大模型长出手脚,自己写SQL查数据库(Function Calling初探)

前面五篇文章,我们一步一步搭起了时序大模型的调用框架。但是你仔细回想一下整个流程,是不是觉得哪里有点别扭?

别扭在什么地方呢?在于我们太像保姆了。我们要自己写代码去连数据库,自己写SQL把数据捞出来,自己把数据洗成文本,再小心翼翼地拼进提示词里喂给大模型。大模型就像个大爷,张嘴吃现成的,吃完吧唧嘴给个评价就走人了。

这种模式在指标少、逻辑固定的时候还能凑合。但如果指标几十个,老板随口问一句"查一下三号车间昨天的平均温度和最高湿度",你就得去改代码写两句SQL。这太累了。

那么有没有一种办法,能让大模型自己去查数据库呢?它那么聪明,它学过SQL,它应该能自己写查询语句啊。

今天这篇,我们就来硬啃一个大招:Function Calling,也就是工具调用。我们要让大模型长出手脚,自己发号施令去查数据。

一、 以前是我们求着模型干活,现在让模型自己发号施令

1.1 回顾一下之前的累人流程

我们先复盘一下以前的流程。老板提了个需求,我们要干好几步活。

第一步,我们要把老板的自然语言翻译成我们的代码逻辑。第二步,代码逻辑去连SQLite,执行写死的SQL。第三步,拿到数据,拼成字符串。第四步,丢给大模型做分析。

在这个过程中,大模型只参与了第四步。前三步这种苦活累活全是我们在干。我们成了大模型和数据库之间的搬运工。

如果哪天老板问的指标变了,或者时间范围变了,我们就得去改SQL,甚至改提示词。这其实就是一种硬编码。只要需求一变,代码就得跟着动。这显然不是长久之计。

1.2 Function Calling是个什么神仙逻辑

那Function Calling是个啥呢?其实说白了,就是大模型不再只动嘴了,它还能动手。

当然,它不能直接跑到你的服务器上去敲键盘。它的工作方式是这样的:我们先给它一本"工具说明书",告诉它我们现在手头有哪些工具可以用。比如我们告诉它,我们有一个叫 query_metrics 的工具,可以查数据库里的监控数据。

然后我们跟它说:"老板想看昨天三号车间下午两点的CPU数据。"

大模型看完工具说明书,它一琢磨,这事儿我得用 query_metrics 这个工具啊。于是它就不直接给你回废话了。它会返回一个结构化的JSON给你,里面写着:"我要调用 query_metrics,参数是 表名=cpu_table时间=昨天14点"。

我们拿到这个JSON之后,在代码里去真正地执行那个查数据库的函数。把查出来的数据,再原封不动地塞回给大模型。

这时候大模型既拿到了老板的问题,又拿到了真实的数据库数据,它再一总结,把人话吐给老板。

你看,在这个流程里,写SQL这种活儿变成谁干了?变成大模型干了。它通过生成参数的方式,告诉了我们要查什么。我们只需要把工具准备好,等它差遣就行了。这就是它长出手脚的意思。

二、 想让模型查数据,得先给它写个"工具说明书"

2.1 说明书里要写些啥

大模型是个瞎子,它不知道你这台机器上能干什么。所以你必须极其详细地描述你的工具。

说明书一般包含这几样东西:

  1. 工具的名字(比如 query_metrics)。
  2. 工具的作用描述(这是最最核心的,大模型就是看这段描述来决定要不要用这个工具的)。
  3. 工具需要什么参数(比如表名、时间范围)。
  4. 每个参数的类型和描述。

这个描述一定要写得大白话一点,但是又要精准。比如你写"查询数据",大模型可能不知道啥时候该用。你得写"当用户需要查询时序监控指标的具体数值时使用此工具,支持按指标名和时间范围过滤"。这样它才懂。

2.2 用JSON Schema把说明书结构化

我们要把这个说明书,拼成API接口能认识的JSON格式。这就是JSON Schema。

我们来看看针对查数据库这个场景,工具说明书长什么样:

python 复制代码
tools = [
    {
        "type": "function",
        "function": {
            "name": "query_metrics",
            "description": "查询服务器监控指标的时序数据。当用户需要获取CPU、内存等具体数值或趋势时,请调用此工具。",
            "parameters": {
                "type": "object",
                "properties": {
                    "metric_name": {
                        "type": "string",
                        "description": "要查询的指标名称,例如:CPU, Memory"
                    },
                    "start_time": {
                        "type": "string",
                        "description": "查询的起始时间,格式为 YYYY-MM-DD HH:MM:SS"
                    },
                    "end_time": {
                        "type": "string",
                        "description": "查询的结束时间,格式为 YYYY-MM-DD HH:MM:SS"
                    }
                },
                "required": ["metric_name", "start_time", "end_time"]
            }
        }
    }
]

你看这段JSON。最外面是个列表,说明我们可以注册多个工具。现在里面只有一个。

function 里面就是具体信息。name 是给代码看的。description 是给大模型看的。

parameters 里面定义了三个参数。我们告诉大模型,调用这个工具必须得告诉我指标名、开始时间和结束时间。而且我们在 required 里声明了,这三个参数缺一不可。如果大模型只给了指标名没给时间,它就不符合规范,我们的代码就可以拒绝执行。

另外注意看,我们特意在 description 里写了时间的格式是 YYYY-MM-DD HH:MM:SS。这很重要。如果不写,大模型可能会给出 昨天下午两点 这种自然语言,我们的代码是解析不了的。必须把格式约束死。

三、 动手写一个真正能查数据库的工具函数

3.1 这个函数是给Python执行的,不是给模型执行的

说明书写好了,接下来我们要把第四篇里写的那个查SQLite的代码稍微封装一下,变成一个能接收上面那些参数的函数。

大模型只会告诉我们参数,真正的活还得我们自己干。

python 复制代码
import sqlite3

def query_metrics(metric_name, start_time, end_time):
    # 这就是我们第四篇写的查数据逻辑,稍微改改参数
    conn = sqlite3.connect('monitor.db')
    cursor = conn.cursor()
    
    sql = """
        SELECT time, metric_name, value 
        FROM metrics 
        WHERE metric_name = ? AND time >= ? AND time <= ?
        ORDER BY time ASC
    """
    
    try:
        cursor.execute(sql, (metric_name, start_time, end_time))
        results = cursor.fetchall()
        
        if not results:
            return "没有查到数据"
            
        # 把查出来的元组洗成文本,方便大模型阅读
        text_lines = []
        for row in results:
            line = f"时间: {row[0]}, 指标: {row[1]}, 数值: {row[2]}%"
            text_lines.append(line)
            
        return "\n".join(text_lines)
        
    except Exception as e:
        return f"查询数据库报错了: {e}"
    finally:
        conn.close()

这个函数接收 metric_namestart_timeend_time。连上数据库,把结果查出来,拼成一段文本返回。

为什么我们要把结果拼成文本返回呢?因为这个结果等会儿还要塞回给大模型。大模型看不懂元组列表,它只认识字符串。所以我们在工具层就把数据洗好,给大模型准备好最容易消化的食物。

四、 串联整个调用的闭环

这一步是今天最烧脑的地方。Function Calling不是一次请求就能搞定的,它是一个来回交涉的过程。最少要走三个回合。

4.1 第一回合:发请求带上工具说明书

我们先发第一次请求。这次请求跟以前不一样,我们要在 payload 里把 tools 带上。

python 复制代码
import requests
import json

def first_round_call(user_question, api_key, tools_list):
    url = "https://ai.timecho.com/v1/chat/completions"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}"
    }
    
    messages = [
        {"role": "user", "content": user_question}
    ]
    
    payload = {
        "model": "timecho-model",
        "messages": messages,
        "tools": tools_list  # 把工具说明书塞进去
    }
    
    response = requests.post(url, headers=headers, json=payload, timeout=30)
    result = response.json()
    
    # 把大模型的回复和当前的messages历史一起返回,后面还要用
    return result, messages

我们拿着老板的问题,带上工具说明书,去问大模型。这时候大模型会怎么回呢?

4.2 判断模型的回复是想聊天还是想干活

大模型拿到问题,它脑子里会盘算:这个问题我是直接回答呢,还是得用工具?

如果它觉得不需要用工具,它返回的格式跟以前一样,choices[0]['message']['content'] 里有段文字。

如果它觉得需要用工具,它就不会回 content 了。它会回一个叫 tool_calls 的字段。

我们要写代码来判断这事儿:

python 复制代码
# 假设这是刚才返回的result
message = result['choices'][0]['message']

# 检查有没有tool_calls字段
if message.get('tool_calls'):
    print("大模型决定使用工具!")
    # 它想干活
else:
    print("大模型直接回答了:")
    print(message.get('content'))

如果它决定用工具,tool_calls 里面的内容大概长这样:

json 复制代码
{
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "query_metrics",
        "arguments": "{\"metric_name\":\"CPU\",\"start_time\":\"2023-10-25 10:00:00\",\"end_time\":\"2023-10-25 10:04:00\"}"
      }
    }
  ]
}

你看,它生成了我们要的参数!name 是工具名,arguments 是我们定义的那些参数的JSON字符串。它终于自己把查询条件给拼出来了。

4.3 第二回合:执行模型给出的参数,把结果塞回去

既然大模型发话了,我们就得照办。我们把 arguments 解析出来,去调我们写的那个 query_metrics 函数。

python 复制代码
# 解析它要调哪个函数
tool_call = message['tool_calls'][0]
function_name = tool_call['function']['name']
arguments_str = tool_call['function']['arguments']
arguments_dict = json.loads(arguments_str)

print(f"它要调用的工具是:{function_name}")
print(f"它给的参数是:{arguments_dict}")

# 真正执行我们的查表函数
if function_name == "query_metrics":
    # 用 ** 把字典展开成关键字参数传进去
    tool_result = query_metrics(**arguments_dict)
    print(f"工具执行结果:\n{tool_result}\n")
else:
    tool_result = "未知的工具"

查到数据了,但这事没完。我们必须把查到的数据再拿去喂给大模型。否则它不知道结果,没法写总结报告。

这里有个极其繁琐但是必须遵守的规范:我们要把大模型刚才的回复、以及工具执行的结果,按照特定的格式拼成历史消息,再发一次请求。

python 复制代码
# 1. 先把大模型刚才的回复(说要调工具的那条)加到历史里
messages.append(message)

# 2. 构造一个工具执行结果的消息,加到历史里
tool_message = {
    "role": "tool",
    "tool_call_id": tool_call['id'],  # 必须带上刚才的id,让大模型对上号
    "content": tool_result  # 把查出来的数据塞进去
}
messages.append(tool_message)

你看,messages 列表现在有三条记录了。第一条是用户的提问,第二条是大模型说要调工具,第三条是我们告诉它工具的结果。

4.4 第三回合:模型根据查出来的数据给出最终的自然语言结论

我们带着这三条消息,再发一次请求。这次请求就不需要带 tools 说明书了,因为它已经不需要再做决策了,它只需要做总结。

python 复制代码
payload_second = {
    "model": "timecho-model",
    "messages": messages  # 带着完整的历史对话
}

response_second = requests.post(url, headers=headers, json=payload_second, timeout=30)
final_result = response_second.json()

print("=== 最终的分析报告 ===")
print(final_result['choices'][0]['message']['content'])

这时候,大模型既看到了老板的问题"查一下CPU数据",又看到了工具查出来的真实数据。它就可以胸有成竹地给出一段漂亮的分析报告了。

五、 交互图

文字看着有点绕,我们画个图把这三步走理一理。
本地查表函数 时序大模型 TimechoAI网关 用户/代码 本地查表函数 时序大模型 TimechoAI网关 用户/代码 #mermaid-svg-41tY5TaVBCgg77y2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-41tY5TaVBCgg77y2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-41tY5TaVBCgg77y2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-41tY5TaVBCgg77y2 .error-icon{fill:#552222;}#mermaid-svg-41tY5TaVBCgg77y2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-41tY5TaVBCgg77y2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-41tY5TaVBCgg77y2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-41tY5TaVBCgg77y2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-41tY5TaVBCgg77y2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-41tY5TaVBCgg77y2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-41tY5TaVBCgg77y2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-41tY5TaVBCgg77y2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-41tY5TaVBCgg77y2 .marker.cross{stroke:#333333;}#mermaid-svg-41tY5TaVBCgg77y2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-41tY5TaVBCgg77y2 p{margin:0;}#mermaid-svg-41tY5TaVBCgg77y2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-41tY5TaVBCgg77y2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-41tY5TaVBCgg77y2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-41tY5TaVBCgg77y2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-41tY5TaVBCgg77y2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-41tY5TaVBCgg77y2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-41tY5TaVBCgg77y2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-41tY5TaVBCgg77y2 .sequenceNumber{fill:white;}#mermaid-svg-41tY5TaVBCgg77y2 #sequencenumber{fill:#333;}#mermaid-svg-41tY5TaVBCgg77y2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-41tY5TaVBCgg77y2 .messageText{fill:#333;stroke:none;}#mermaid-svg-41tY5TaVBCgg77y2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-41tY5TaVBCgg77y2 .labelText,#mermaid-svg-41tY5TaVBCgg77y2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-41tY5TaVBCgg77y2 .loopText,#mermaid-svg-41tY5TaVBCgg77y2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-41tY5TaVBCgg77y2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-41tY5TaVBCgg77y2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-41tY5TaVBCgg77y2 .noteText,#mermaid-svg-41tY5TaVBCgg77y2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-41tY5TaVBCgg77y2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-41tY5TaVBCgg77y2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-41tY5TaVBCgg77y2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-41tY5TaVBCgg77y2 .actorPopupMenu{position:absolute;}#mermaid-svg-41tY5TaVBCgg77y2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-41tY5TaVBCgg77y2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-41tY5TaVBCgg77y2 .actor-man circle,#mermaid-svg-41tY5TaVBCgg77y2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-41tY5TaVBCgg77y2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 发请求(用户问题 + 工具说明书)转发请求2. 我要用query_metrics, 参数是...返回tool_calls指令3. 执行query_metrics(CPU, 10:00, 10:04)4. 返回真实数据文本5. 发请求(历史问题 + 工具结果)转发带数据的上下文6. 根据数据生成人话总结返回最终content

你看这个时序图,这就叫闭环。大模型不再是光说不练了。它通过生成参数,指挥了我们的代码干活。我们的代码干完活,把结果汇报给它。它再出面把事情圆完。

这就是现在大模型应用开发最主流的架构模式,叫做Agent架构的基础形态。只要理解了这个三步走,后面再复杂的什么多工具联动、自主规划,都是在这个基础上堆逻辑而已。

六、 必踩的坑:模型生成的参数格式不对怎么办

Function Calling 很强,但是它不完美。最让人头疼的一个坑就是:大模型有时候会犯迷糊,生成的参数格式不对。

6.1 幻觉:它给的时间格式不带引号或者拼错了

比如我们在说明书里写了,时间格式必须是 YYYY-MM-DD HH:MM:SS

大多数情况下它会很听话地给 2023-10-25 10:00:00。但是偶尔,如果用户的提问很诡异,它可能给个 昨天下午 出来。

一旦它给了这种参数,我们的 query_metrics 函数去查数据库肯定查不到东西啊。SQL里的时间匹配就会失败。

怎么防呢?这就需要在我们的工具执行代码里加极强的容错逻辑。

python 复制代码
try:
    arguments_dict = json.loads(arguments_str)
    # 可以在这里加正则校验,如果时间格式不对,直接打回重做
    # 这里为了简单,我们只做异常捕获
    tool_result = query_metrics(**arguments_dict)
except json.JSONDecodeError:
    tool_result = "参数JSON解析失败,请检查格式。"
except TypeError:
    tool_result = "缺少必要的参数,请提供完整的指标名和时间范围。"
except Exception as e:
    tool_result = f"工具执行出错: {e}"

我们把执行工具的代码用 try...except 严严实实地包起来。如果它给的参数不对,我们的函数不会崩,而是会返回一段报错文字。

这段报错文字,我们照样塞到 tool_message 里发回给大模型。大模型看到"参数解析失败",它就知道自己干坏事了。它会反思一下,换个格式再试一次。这就相当于它自己在做调试。

6.2 它瞎编了一个我们不支持的函数名

还有一种情况。如果你给它注册了两个工具,一个查CPU,一个查内存。结果它脑抽了,返回了一个 function_name: "query_disk"

我们代码里根本没有这个函数。这时候如果直接调,肯定会报 AttributeError

所以我们在调用的地方,一定要写 if...elif...else 把函数名过滤一遍。碰见不认识的,直接返回"系统不支持此工具"。

python 复制代码
if function_name == "query_metrics":
    tool_result = query_metrics(**arguments_dict)
elif function_name == "another_tool":
    # tool_result = another_tool(**arguments_dict)
    pass
else:
    tool_result = f"错误:不支持名为 {function_name} 的工具。"

不要小看这些防御性代码。在真实的工作里,80%的时间都在写这种兜底逻辑。大模型是不稳定的,只有我们的代码够硬,才能把它的输出给兜住。

七、 去官方文档和示例验证可行性

7.1 看看文档里关于工具调用的说明

Function Calling 这个能力,对大模型本身的指令跟随能力要求极高。不是随便哪个模型都能玩转的。

所以你在开干之前,一定要去翻一翻官方的开发文档:https://ai.timecho.com/docs/

在文档里搜一下 tools 或者 function_call 相关的章节。看看TimechoAI的这套接口,是不是完全兼容OpenAI的那种格式。

如果是兼容的,那我们上面写的那些JSON结构就能直接跑。如果它有自己的私有规范,比如字段名不叫 toolsplugins,那你得按它的来。不过现在业界的大趋势都是往OpenAI靠拢,通常来说大差不差。

7.2 去示例页面体验类似效果

你也可以去应用示例页面:https://ai.timecho.com/realtime 看看。

有些官方的示例页面,会把工具调用的过程在前端打印出来。比如它会显示"正在调用查询数据库工具...",然后过一会才出结果。

如果你在示例页面看到了这种标志,那就说明这套逻辑官方已经调通了,你放心用就行。你只要照着我们今天写的三步走逻辑,把代码串起来,绝对没问题。

八、 总结与下期预告

8.1 今天的逻辑有点绕,多跑几遍代码

今天这篇信息量非常大。我们从以前单纯的提示词工程,跨越到了Agent的领域。大模型从只会动嘴,变成了会动手指挥的工具调用者。

这个三步走的逻辑(发说明书 -> 拿参数执行 -> 发结果总结)确实有点绕。如果你看一遍没看懂,别灰心,这很正常。这是大模型应用开发里的一道坎。

最好的办法就是自己把代码敲出来,多打几个 print,看看每一步返回的JSON到底长什么样。你只要跑通了一次,看到大模型自己把参数填进去查数据库的那一瞬间,你绝对会感到非常震撼。那种感觉就像是你教出来的傻子突然开窍了一样。

8.2 下期我们讲讲:如果模型想一次查两个表怎么办

今天我们只让它查了一次数据库。但是有些复杂的问题,它可能需要查两次。

比如老板问:"对比一下昨天和前天的CPU峰值"。大模型可能需要先调一次工具查昨天的,再调一次工具查前天的。它可能会在 tool_calls 里一次性返回两个调用请求。

那我们的代码怎么处理并行调用呢?这又是一个大坑。如果搞不定,这套Agent就只能干点简单的活。

所以下一期,我们就来死磕多工具并行调用的问题。把这个解决了,我们的Agent基本就毕业了。我们下篇见。

相关推荐
锋行天下1 小时前
LangGraph 模拟大模型调用工具失败后重试直到成功
人工智能
FL16238631291 小时前
胸片诊断肺炎结核积液识别分割数据集labelme格式3751张4类别
人工智能
天远数科2 小时前
零信任架构实战:基于天远全能消金报告构建自动化信贷评估网关
运维·人工智能·架构·自动化
yume_sibai2 小时前
06-Rust Web 开发实战(Axum 框架 + 数据库 + JWT 认证 + 中间件 + 部署)
前端·数据库·rust
云上先途2 小时前
属性标注是什么?主要解决什么问题?
大数据·人工智能
罗西的思考2 小时前
[Agent Memory / 强化学习] MemPO源码学习笔记 --- (2)--- 训练
人工智能·算法·机器学习
米小虾2 小时前
LLM评测的失效与依赖感知聚合
人工智能·llm
幂律智能2 小时前
不用逐站检索,AI 自动出具财产线索报告
人工智能
小马过河R2 小时前
FDE 到底需要什么技术栈?从全栈工程到 AI 落地的复合能力
人工智能·engineering·harness·驾驭工程·fde·前沿部署工程师