从零实现一个基于 Ollama + Go + MySQL 的 Text-to-SQL 智能体(M1 实战)

前言

最近在搭建一个私有化的 AI Agent,目标是让大模型能安全地查询企业内部数据库。作为一个运维出身、正在学习 Go 和云原生的人,我不想只当"调包侠",而是想亲手打通从"自然语言 → 工具调用 → 数据库查询 → 自然语言回答"的全链路。

今天这篇文章记录了 M1 里程碑 的完整实现过程:在一台 Linux 虚拟机上,用 Go 语言调用 Ollama(Qwen2.5 7B 模型),结合 MySQL,实现一个能够回答"销售部平均工资是多少?"的 Text-to-SQL 智能体

整个过程不依赖 K8s、不涉及复杂的 MCP 协议,只聚焦于最核心的 Tool Use(函数调用) 机制。读完你将明白:

  • 大模型如何通过 tool_calls 输出结构化指令

  • Go 程序如何解析并执行这些指令

  • 如何将查询结果再次交给模型总结成人话

环境准备

硬件/软件环境

  • 一台 Linux 虚拟机(Ubuntu 22.04 / CentOS 9)

  • Docker 及 Docker Compose(可选)

  • Go 1.21+

  • 足够的内存(至少 8GB,用于运行 7B 模型)

安装 Docker 并运行 Ollama

bash

复制代码
# 安装 Docker
curl -fsSL https://get.docker.com | bash
sudo systemctl enable --now docker

# 运行 Ollama 容器,暴露 API 端口 11434
docker run -d --name ollama -p 11434:11434 ollama/ollama

# 拉取 qwen2.5:7b 模型(约 4GB,耐心等待)
docker exec -it ollama ollama pull qwen2.5:7b

验证模型是否可用:

bash

复制代码
curl http://localhost:11434/api/tags

启动测试 MySQL

bash

复制代码
docker run --name mysql-test -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -d mysql:8.0

# 创建数据库和表
docker exec -i mysql-test mysql -uroot -p123456 <<EOF
CREATE DATABASE company;
USE company;
CREATE TABLE employees (
    id INT,
    name VARCHAR(50),
    dept VARCHAR(50),
    salary INT
);
INSERT INTO employees VALUES (1, '张三', 'sales', 70000);
INSERT INTO employees VALUES (2, '李四', 'sales', 80000);
INSERT INTO employees VALUES (3, '王五', 'engineering', 100000);
EOF

注意:部门名我用小写 sales,与大模型生成 SQL 的习惯一致,避免后续大小写问题。

核心代码实现

创建一个目录 ~/mcp-demo,新建 main.go。下面分块解释。

1. 定义数据结构(与 Ollama API 通信)

go

复制代码
type Message struct {
    Role      string     `json:"role"`
    Content   string     `json:"content,omitempty"`
    ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}

type ToolCall struct {
    Function struct {
        Name      string          `json:"name"`
        Arguments json.RawMessage `json:"arguments"` // 注意:这里是 RawMessage
    } `json:"function"`
}

type Tool struct {
    Type     string   `json:"type"`
    Function Function `json:"function"`
}

type Function struct {
    Name        string                 `json:"name"`
    Description string                 `json:"description"`
    Parameters  map[string]interface{} `json:"parameters"`
}

关键点:Argumentsjson.RawMessage,因为模型返回的是一个 JSON 对象(例如 {"sql": "SELECT ..."}),而不是字符串。

2. 调用 Ollama 的函数

go

复制代码
func callOllama(messages []Message, tools []Tool) (OllamaResponse, error) {
    reqBody := OllamaRequest{
        Model:    "qwen2.5:7b",
        Messages: messages,
        Tools:    tools,
        Stream:   false,
    }
    jsonData, _ := json.Marshal(reqBody)
    resp, err := http.Post("http://localhost:11434/api/chat", "application/json", bytes.NewBuffer(jsonData))
    if err != nil {
        return OllamaResponse{}, err
    }
    defer resp.Body.Close()
    bodyBytes, _ := io.ReadAll(resp.Body)
    var result OllamaResponse
    json.Unmarshal(bodyBytes, &result)
    return result, nil
}

3. 执行 SQL 查询

go

复制代码
func executeSQL(sqlStr string) string {
    db, _ := sql.Open("mysql", "root:123456@tcp(localhost:3306)/company")
    defer db.Close()
    rows, err := db.Query(sqlStr)
    if err != nil {
        return fmt.Sprintf("Query error: %v", err)
    }
    defer rows.Close()
    // 将结果转换为 JSON 字符串
    // ... (完整代码见文末)
    return string(jsonBytes)
}

4. 主流程:两轮调用

go

复制代码
func main() {
    // 定义工具
    tools := []Tool{
        {
            Type: "function",
            Function: Function{
                Name:        "query_sql",
                Description: "Execute SELECT SQL. Table employees has columns: id, name, dept, salary.",
                Parameters: map[string]interface{}{
                    "type": "object",
                    "properties": map[string]interface{}{
                        "sql": map[string]string{"type": "string"},
                    },
                    "required": []string{"sql"},
                },
            },
        },
    }

    userQuestion := "销售部的平均工资是多少?"
    messages := []Message{{Role: "user", Content: userQuestion}}

    // 第一轮:带 tools 调用,期望模型返回 tool_calls
    resp, _ := callOllama(messages, tools)
    tc := resp.Message.ToolCalls[0]

    var args struct{ SQL string `json:"sql"` }
    json.Unmarshal(tc.Function.Arguments, &args)
    fmt.Println("执行 SQL:", args.SQL)
    resultJSON := executeSQL(args.SQL)

    // 第二轮:将工具结果发送给模型,不再提供 tools
    secondMessages := []Message{
        {Role: "user", Content: userQuestion},
        resp.Message, // assistant 的 tool_calls 消息
        {Role: "tool", Content: resultJSON},
    }
    finalResp, _ := callOllama(secondMessages, nil)
    fmt.Println("最终回答:", finalResp.Message.Content)
}

运行与调试

第一次运行遇到的问题

  • 报错 Query was empty :因为模型返回的 arguments 是对象,但代码中定义为 string,导致解析后 sql 字段为空。

  • 修复 :将 Arguments 类型改为 json.RawMessage

  • 第二次报错 Unknown column 'dept' :因为数据库列名是 department,而模型生成的是 dept

  • 修复 :统一列名为 dept,并将部门值改为小写。

最终成功输出

text

复制代码
📤 请求 Ollama: {...}
📡 Ollama 原始响应: {"message":{"tool_calls":[{"function":{"name":"query_sql","arguments":{"sql":"SELECT AVG(salary) FROM employees WHERE dept = 'sales'"}}}]}}
🔧 执行 SQL: SELECT AVG(salary) FROM employees WHERE dept = 'sales'
📊 查询结果: [{"avg_salary":"75000.0000"}]
🤖 最终回答: 销售部的平均工资是75,000元。

关键收获

  1. Tool Use 本质:大模型并不真正"执行"任何操作,它只是输出一个结构化的 JSON,你的程序负责解析并执行。

  2. 两轮调用模式 :第一轮带工具定义,模型返回 tool_calls;第二轮将执行结果作为 tool 角色消息发回,模型总结输出。

  3. 调试技巧 :打印 Ollama 原始响应,观察 arguments 的实际格式,有助于快速定位反序列化问题。

  4. 数据一致性:表结构、列名、数据值的大小写都会影响模型生成的 SQL,需要在工具描述中明确提示,或通过数据库设计保持一致。

下一步计划

本文实现的是最简版本(一个工具、一轮工具调用)。接下来将:

  • 将 SQL 执行器拆分为独立进程,通过 JSON-RPC over stdio 通信(向 MCP 协议靠拢)。

  • 实现 ReAct 循环:让模型在遇到错误时能够自我修正,重新生成工具调用。

  • 容器化并使用 Kubernetes 部署整个系统。


写在最后:从零开始理解 AI Agent 的内部机制并不难,关键是分步拆解、亲手调试。希望这篇文章能帮助更多像你一样的开发者跨过"只会调 API"的门槛,真正掌握 AI 工程化的底层原理。如果你在实现过程中遇到任何问题,欢迎留言交流。

相关推荐
yuyuyui9 小时前
Mysql事物的持久性及原子性
mysql·.net core
basketball61610 小时前
SQL 基础面试考点总结
数据库·sql·面试
Java成神之路-10 小时前
OR 真的会导致索引失效吗?
mysql
woshilys11 小时前
sql server 查询外键
数据库·sql·sqlserver
隐层漫游者13 小时前
SQL核心技能全景图:DDL数据定义、DML安全操作、DQL高级查询、多表JOIN与窗口函数实战
mysql
雨辰AI13 小时前
人大金仓慢 SQL 根治方法论:问题定位 - 分析 - 优化全流程
数据库·后端·sql·mysql·政务
LCG元13 小时前
MySQL慢查询分析与索引调优:从故障诊断到性能翻倍的进阶之路
android·前端·mysql
问心无愧051314 小时前
ctf show web 入门173
数据库·笔记·sql·mysql
向上的车轮14 小时前
何时使用Serverless?
云原生·serverless