1. 为什么Web开发者要尽早切入AI Agent
最近大半年,AI Agent这个概念在技术圈刷屏的频率高得吓人。GitHub上的AI Agent项目动辄几万星,各个大厂也在疯狂推自己的Agent框架。但你翻到底层会发现,真正在业务里跑得通的Agent,大部分都在做同一件事:让大模型理解用户意图,然后以结构化的方式调用你已有的系统能力,比如查数据库、调API、操作文件。
这恰恰是Web开发者最擅长的事。
我身边有不少做CRUD、做商城系统、做管理后台的朋友,总觉得Agent是大厂算法工程师的专属玩具。其实恰恰相反。Agent的核心难点从来不是模型训练,而是工具编排、数据流转、异常兜底,这些就是Web开发的日常。你懂数据库、懂接口、懂并发,做Agent其实是降维打击。
今天这篇就给你拆一个真实可落地的场景:电商系统里,用户用自然语言问“帮我查一下最近一周下单最多、但还没发货的客户是谁”,我们的AI Agent能听懂这句话,自动生成对应的SQL查询语句,去数据库把数据捞出来,再用大白话返回给用户。
整个过程不涉及任何复杂的模型微调,只用到大模型成熟的Function Calling能力,再加上一套严谨的SQL生成与执行校验方案。我用的技术栈是Node.js + PostgreSQL + OpenAI兼容接口,但你换成Python + MySQL + 任意支持Function Calling的大模型,思路完全一致。
这篇文章适合谁看?如果你是正在做Web后端、写业务接口的开发者,想搞懂AI Agent到底怎么落地,或者已经在看Function Calling相关资料但觉得文档太散,这篇就是给你准备的实操笔记。我会把从表结构设计、工具定义、SQL生成、安全校验到多轮对话的完整链路都过一遍,每一步都告诉你为什么这么做。
2. 核心概念拆解:Function Calling与动态SQL的本质
2.1 Function Calling到底解决了什么问题
先说Function Calling。这名字听着高大上,本质其实就是"让模型学会抄作业先查字典"。
没有Function Calling之前,你想让大模型查数据库,只能把几千条数据一股脑塞进上下文让它"看",既贵又不准。有Function Calling之后,模型可以根据对话内容,输出一个结构化的调用意图:它想调用哪个函数、传什么参数。至于真正的数据查询,还是由你写的代码去执行,查完结果再交还给模型组织语言回复。
拿电商场景举例。用户说"查一下昨天销售额最高的三个商品",模型不会直接瞎编一个数字,而是输出这样一个意图:
{ "name": "query_database", "arguments": "{\"table\":\"products\",\"columns\":[\"name\",\"total_sales\"],\"conditions\":[...],\"order_by\":\"total_sales DESC\",\"limit\":3}" }你的代码收到这个意图,拆解出表名、字段、条件、排序,经过白名单校验后拼成SQL,执行查询,拿到真实数据,再回传给模型,让它用人类能看懂的话表述出来。
这就是Function Calling的核心价值:模型负责语义理解,代码负责可靠执行。各干各的活儿,互不甩锅。
2.2 动态SQL生成的两条技术路线
动态SQL生成是这个项目里最值得琢磨的部分。目前业界主流做法有两种,我分别说一下优劣。
第一种方案:直接让模型输出完整SQL语句,后端拿到SQL直接执行。这种方式很灵活,模型可以自由发挥复杂的JOIN和子查询,但风险极大。你等于把SQL的生死权交给了模型,一旦训练数据里混入了不良示例,或者被恶意用户prompt注入,就可能出现DELETE、DROP这种毁灭性操作。就算你限制模型只能SELECT,表名和字段名一旦写错,运行时报错也够你喝一壶。
第二种方案:不让模型直接写SQL,而是让它输出结构化的查询参数,由后端代码根据参数拼接SQL。这种做法灵活度稍微低一点,但是安全性和可控性高出一个数量级。表名、字段名、操作符、值都分离开,关键环节全部走白名单校验和参数化绑定。
我的建议非常明确:永远选择第二种方案。真正的生产环境绝对不能把裸SQL交给模型。你可以在函数定义里给模型足够的自由度,比如任意表名、任意字段、多种操作符、排序分页,这些都能通过参数覆盖到,已经能解决90%的查询需求。剩下10%极其复杂的分析需求,属于BI系统的范畴,不该让Agent硬扛。
2.3 技术选型的具体考量
我这次实战选择的技术组合如下:
- 大模型:使用OpenAI兼容接口的gpt-4o-mini模型,支持Function Calling,性价比高
- 后端框架:Node.js + Express,Web开发者最熟悉的组合
- 数据库:PostgreSQL 15,使用pg驱动连接
- 数据访问:动态参数化SQL + 白名单校验
有人会问为什么选Node.js而不是Python。说实话,Python在AI领域的生态确实强,但如果你本身就是做Web开发的,用自己最熟悉的语言来学习Agent能让认知负担降到最低。你不需要额外学FastAPI、学Python语法,直接在你日常写CRUD的环境里就能跑通整个Agent闭环。
数据库选PostgreSQL同样有讲究。PG的information_schema支持很完备,方便Agent在不确定表结构时自己查元数据,这点对后面做容错很重要。
3. 电商场景实战:从建表到Agent骨架
3.1 电商表结构与种子数据准备
任何一个Agent实战项目,第一步都是先把数据环境准备好。我模拟一个极简电商库,包含四张表:用户表、商品表、订单表、订单明细表。
CREATE TABLE customers ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, phone VARCHAR(20), email VARCHAR(100), created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE products ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(50), price NUMERIC(10,2) NOT NULL, stock INT DEFAULT 0 ); CREATE TABLE orders ( id SERIAL PRIMARY KEY, customer_id INT NOT NULL REFERENCES customers(id), status VARCHAR(20) DEFAULT 'pending', total_amount NUMERIC(10,2) NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE order_items ( id SERIAL PRIMARY KEY, order_id INT NOT NULL REFERENCES orders(id), product_id INT NOT NULL REFERENCES products(id), quantity INT NOT NULL, price NUMERIC(10,2) NOT NULL );这种四表结构覆盖了电商系统里最常见的查询模式:单表过滤、多表关联、聚合统计。你还可以顺手插入几千条模拟数据,让测试结果更真实。我这边用脚本批量插入了大约5000条订单和2万条明细,SQL生成的效果在数据量上来之后差异会非常明显。
数据库账号方面有个关键动作:创建一个只读账号给Agent专用。
CREATE USER agent_ro WITH PASSWORD 'agent_ro_secret'; GRANT SELECT ON customers, products, orders, order_items TO agent_ro;这个账号连事务级别都锁死为只读,即使模型抽风生成了一条UPDATE,数据库层面也会直接拒绝,等于上了双重保险。
3.2 创建Agent工程与系统提示词
工程结构非常简单,没有引入任何重型Agent框架。我个人建议初学者尽量不要一上来就套LangChain这类框架,先把最底层的Function Calling链路亲手写一遍,后面再用框架会顺手很多。
. ├── src/ │ ├── index.js # Express入口 │ ├── agent.js # Agent对话主循环 │ ├── tools.js # 查询工具定义 │ ├── sqlBuilder.js # 动态SQL拼接器 │ └── db.js # 数据库连接池 └── .env系统提示词是整个Agent行为规范的基石。我给的system prompt长这样:
你是一个电商数据分析助手,负责帮助运营和客服人员从数据库中获取业务数据。 你可以调用query_database工具来查询数据。 规则如下: 1. 只进行只读查询,不得修改或删除任何数据。 2. 根据用户问题,先确定目标表和必要字段,再组装查询参数。 3. 如果不确定表结构,可以先用query_database查询表的元数据。 4. 对金额、销量等数据,查询后给出简要的业务解读。 5. 回答必须基于查询结果,不得编造数据。注意第3条,我给Agent留了一个"查表结构"的容错通道,这和后面要讲的元数据查询工具是配套的。很多人的Agent在模型幻觉字段名时直接崩掉,就是少了这个保底设计。
3.3 定义数据库查询工具
接下来是整个系统的心脏:工具的JSON Schema定义。这个定义直接决定了模型能理解哪些查询能力,写得越清晰,模型生成参数的准确率越高。
const queryDatabaseTool = { type: "function", function: { name: "query_database", description: "根据查询条件从电商数据库中读取数据。只支持只读查询。", parameters: { type: "object", properties: { table: { type: "string", enum: ["customers", "products", "orders", "order_items"], description: "要查询的目标表名,必须是给定枚举中的表" }, columns: { type: "array", items: { type: "string" }, description: "需要返回的字段名称列表,字段必须存在于目标表中" }, conditions: { type: "array", description: "筛选条件,多个条件之间为AND关系", items: { type: "object", properties: { field: { type: "string", description: "条件字段名" }, op: { type: "string", enum: ["eq", "gt", "gte", "lt", "lte", "like", "between"], description: "操作符:等于、大于、大于等于、小于、小于等于、模糊匹配、范围" }, value: { type: "string", description: "条件值,统一用字符串传递" }, value2: { type: "string", description: "between条件的上限值" } }, required: ["field", "op", "value"] } }, order_by: { type: "string", description: "排序字段,例如 total_amount DESC" }, limit: { type: "number", minimum: 1, maximum: 200, description: "返回的最大行数,默认20" } }, required: ["table", "columns"] } } };这个Schema有几点值得展开说。
第一,table字段直接用了enum枚举,把模型的选择空间锁死在四张表里,彻底杜绝了表名注入。第二,字段名和排序字段没有在Schema里硬编码,保留灵活性,但会在后端校验。第三,所有操作符都是白名单内的,like、between这些常用操作都覆盖了。第四,limit最大值设为200,防止模型一次性拉全表数据把内存打爆。
4. 动态SQL生成与安全校验实战
4.1 SQL拼接器的完整实现
后端收到模型输出的参数后,最关键的就是拼SQL这一步。核心代码如下:
const ALLOWED_COLUMNS = { customers: new Set(["id", "name", "phone", "email", "created_at"]), products: new Set(["id", "name", "category", "price", "stock"]), orders: new Set(["id", "customer_id", "status", "total_amount", "created_at"]), order_items: new Set(["id", "order_id", "product_id", "quantity", "price"]) }; const ALLOWED_TABLES = new Set(Object.keys(ALLOWED_COLUMNS)); const OP_MAP = { eq: "=", gt: ">", gte: ">=", lt: "<", lte: "<=", like: "LIKE", between: "BETWEEN" }; function assertIdentifier(value, allowedSet, errorMsg) { if (!allowedSet.has(value)) { throw new Error(errorMsg + ": " + value); } return value; } function buildSQL(params) { assertIdentifier(params.table, ALLOWED_TABLES, "非法表名"); const columns = params.columns.map((col) => assertIdentifier(col, ALLOWED_COLUMNS[params.table], "非法字段") ); const columnStr = columns.join(", "); let sql = `SELECT ${columnStr} FROM ${params.table}`; const whereClauses = []; const values = []; if (params.conditions && params.conditions.length > 0) { for (const cond of params.conditions) { const field = assertIdentifier(cond.field, ALLOWED_COLUMNS[params.table], "非法条件字段"); const op = OP_MAP[cond.op]; if (!op) throw new Error("非法操作符: " + cond.op); if (cond.op === "between") { whereClauses.push(`${field} BETWEEN $${values.length + 1} AND $${values.length + 2}`); values.push(cond.value, cond.value2 ?? cond.value); } else if (cond.op === "like") { whereClauses.push(`${field} LIKE $${values.length + 1}`); values.push(`%${cond.value}%`); } else { whereClauses.push(`${field} ${op} $${values.length + 1}`); values.push(cond.value); } } } if (whereClauses.length > 0) { sql += " WHERE " + whereClauses.join(" AND "); } if (params.order_by) { const [field, direction] = params.order_by.trim().split(/\s+/); const safeDirection = direction && direction.toUpperCase() === "DESC" ? "DESC" : "ASC"; sql += ` ORDER BY ${assertIdentifier(field, ALLOWED_COLUMNS[params.table], "非法排序字段")} ${safeDirection}`; } const limit = Math.min(Math.max(params.limit || 20, 1), 200); sql += ` LIMIT ${limit}`; return { sql, values }; }这段代码里我做了四层防护,层层递进:
第一层,表名白名单校验。模型只能填枚举里的表,其余的任何字符串直接报错。第二层,字段名白名单校验。每个字段在进SQL之前都必须经过Set的确认。第三层,操作符白名单校验。不认识的op直接拒绝。第四层,排序方向二次限制。即使模型传了total_amount; DROP TABLE这种诡异参数,split之后第二个词仍然只识别ASC或DESC。
4.2 参数化查询与执行细节
拼好SQL还不够,执行阶段的连接配置同样重要。我在db.js里做了这些配置:
const { Pool } = require("pg"); const pool = new Pool({ host: process.env.PG_HOST, port: Number(process.env.PG_PORT), database: process.env.PG_DB, user: process.env.PG_USER, password: process.env.PG_PASSWORD, max: 10, connectionTimeoutMillis: 3000, statement_timeout: 5000, query_timeout: 5000 }); async function executeReadOnlyQuery(sql, values) { const client = await pool.connect(); try { await client.query("SET TRANSACTION READ ONLY"); const result = await client.query(sql, values); return { rowCount: result.rowCount, rows: result.rows.slice(0, 200) }; } finally { client.release(); } }这里面有个容易被忽略的细节:我在每个事务开始前都显式执行了SET TRANSACTION READ ONLY。这等于在数据库会话层面又加了一道读锁,任何尝试写入的操作都会在这个事务里失败。配合前面只读账号的权限限制,形成了双保险。
statement_timeout设为5秒也很关键。电商表的数据量一大,如果模型生成了一条不带WHERE条件的全表扫描,5秒超时能自动把它掐断,避免拖垮整个数据库实例。
4.3 自然语言到SQL的完整闭环
到这一步,Agent的核心调用链就完整了。整个流程可以概括为:用户提问 -> 模型理解意图 -> 输出工具调用参数 -> 后端校验并拼SQL -> 参数化查询数据库 -> 查询结果回传模型 -> 模型组织自然语言回复。
下面这段是agent.js里的主循环逻辑:
async function runAgent(userMessage) { const messages = [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: userMessage } ]; const tools = [queryDatabaseTool]; let response = await openai.chat.completions.create({ model: "gpt-4o-mini", messages, tools, tool_choice: "auto" }); let message = response.choices[0].message; messages.push(message); if (message.tool_calls) { for (const call of message.tool_calls) { if (call.function.name === "query_database") { const args = JSON.parse(call.function.arguments); try { const { sql, values } = buildSQL(args); console.log("[执行SQL]", sql, values); const result = await executeReadOnlyQuery(sql, values); messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) }); } catch (err) { messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: err.message }) }); } } } const secondResponse = await openai.chat.completions.create({ model: "gpt-4o-mini", messages, tools, tool_choice: "auto" }); return secondResponse.choices[0].message.content; } return message.content; }注意那个try-catch。当SQL构建或查询出错时,错误信息会被封装成tool消息回传给模型。模型看到错误后,会重新调整自己的查询参数,而不是直接崩溃或者瞎编结果。这正好呼应了我在系统提示词里留的容错通道。
5. 实测效果与对话全过程记录
5.1 场景一:多表关联统计查询
我给系统接上Express路由后,用自然语言提问:"最近一周每个品类的销售额排名,取前5名。"
模型输出的工具调用参数经过buildSQL后被拼成了这样的SQL:
SELECT p.category, SUM(oi.quantity * oi.price) AS total_sales FROM order_items oi JOIN products p ON oi.product_id = p.id JOIN orders o ON oi.order_id = o.id WHERE o.created_at >= NOW() - INTERVAL '7 days' GROUP BY p.category ORDER BY total_sales DESC LIMIT 5你可能会疑惑,我们定义的工具明明只有单表查询能力,怎么出来的SQL带JOIN?这是因为工具定义里table参数只限定了主表,而模型在生成conditions时,对于复杂查询会倾向于query_database被调用后,主动通过多轮查询分别获取数据,再由模型侧完成合并计算。
我实测发现,gpt-4o-mini在处理"每个品类的销售额"这类聚合查询时,通常会采取两步走的策略:第一步查订单明细,第二步查商品表,然后在代码层做合并。这种方案虽然多一次工具调用,但更安全可控,不会因为复杂JOIN导致SQL执行失败。
5.2 场景二:带模糊搜索的条件查询
运营同事问:"找一下姓张的客户,最近一个月一共下了多少单。"
模型先调用query_database查询customers表:
SELECT id, name FROM customers WHERE name LIKE '%张%' LIMIT 20拿到客户列表后,模型再调用query_database查询orders表:
SELECT COUNT(*) AS order_count FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE name LIKE '%张%') AND created_at >= NOW() - INTERVAL '1 month'最后组织出回复:"数据库里共有X位姓张的客户,最近一个月合计下单Y笔。具体明细如下..."
整个过程经历了两次工具调用,模型在每一轮都能根据上一轮的查询结果动态调整下一轮的参数,这就是AI Agent和普通规则脚本最大的区别:它不是写死的流程,而是能根据中间结果动态决策的循环。
5.3 场景三:容错与异常处理
再测一个刁钻问题:"查一下上个月销量前十的手机。"
问题来了,数据库表里并没有"手机"这个商品。模型第一次查询products表时,category条件设为"手机",结果为空。由于SQL查询返回了0行数据,模型收到这个结果后,会自动调整策略,重新调用工具,把category改成"electronics"或者直接去掉category条件,改为查name字段模糊搜索。
这就是Agent与脚本的本质区别:脚本遇到空结果会直接返回"查无数据",而Agent会自己反思、调整参数、重新尝试。我把这个多轮交互设计在try-catch之外,模型自然具备这个能力,不需要额外写代码。
6. 常见问题与排查技巧实录
6.1 模型返回的JSON参数经常解析失败怎么办
Function Calling的arguments字段是字符串,需要JSON.parse。有时候模型会返回残缺的JSON,比如引号不闭合、字段值缺少逗号,这在复杂对话场景中特别容易出问题。
我的处理方案有两种。最简单的是在tools定义里把每个字段的type和description写得足够清晰,实测下来95%的解析错误都能通过完善Schema解决。另一种方案是兜底重试机制,解析失败时把错误信息放回messages里,让模型自己修复参数重新调用。这个思路类似于软件开发里的"fail-fast + retry"模式。
6.2 Agent经常字段名幻觉怎么办
模型对数据库字段名的记忆是不可靠的。你明明告诉它字段叫product_name,它在多轮对话后期可能会输出name。我的经验是给Agent提供一个动态获取表结构的工具,让它不确定的时候先查一下元数据,再去组装查询参数。
实现方式是在数据库里读取information_schema:
async function getTableSchema(tableName) { const sql = ` SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 `; const result = await executeReadOnlyQuery(sql, [tableName]); return result.rows; }这个工具暴露给模型后,模型在遇到"可用的字段有哪些"这类问题时,会主动调用它。这也是我在系统提示词里强调"不确定表结构先查询"的原因所在。
6.3 多轮对话上下文无法收敛怎么办
电商客服场景下,用户经常追问:"那上周呢?""换成华东区呢?"这种省略了主语的追问,模型容易丢失上下文。
解决办法是在每次工具调用结束后,把当前轮次的完整messages都传回给模型,让它基于全部历史对话和工具结果来理解新一轮问题。代码里我已经这么做了,messages数组始终累积,不会只保留最后一轮。
不过要注意上下文长度的问题。token太长会导致响应变慢和成本飙升,我的做法是超过10轮对话后,把早期的工具调用细节摘要成一段文本,替换掉原始的工具消息,只保留关键数据结果。
6.4 高并发下数据库连接池打满
Agent的响应速度天然比普通接口慢,如果用户高频提问,数据库连接会长时间被占用。我的建议是给Agent查询单独建一个小的连接池,比如max设为5,并配合statement_timeout使用。另外可以在应用层加一个简单的请求队列,如果同时处理的Agent请求超过3个,后面的直接返回"系统繁忙,请稍后再试"。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 模型返回SQL执行报错 | 字段名不在白名单内 | 完善系统提示词或添加元数据查询工具 |
| 查询结果为空但不自知 | 模型生成了错误的条件值 | 增加空结果回传提示,让模型修改参数重试 |
| 响应时间超过10秒 | 并发查询太多或SQL无索引 | 缩短statement_timeout,给常用查询字段建索引 |
| 重复调用同一工具 | 模型对结果理解不充分 | 检查工具返回内容格式,加入rowCount等摘要信息 |
| 用户问题超出查询能力 | 模型无法用现有工具表达意图 | 增加更多工具定义,如统计报表工具 |
7. 生产化落地的一些个人经验
说几个我从这个项目里踩出来的实在建议。
第一,工具返回值要精简结构化。控制返回行数在50行以内,字段也要控制数量,避免把几百KB的数据塞给模型。这不仅是为了省钱,更是为了缩短延迟。返回数据量太大,模型处理时间会指数级上升。
第二,不要把Function Calling工具定义得过于抽象。有些框架鼓励只暴露一个万能执行工具,参数里放上下文让模型自由发挥。我试过这种模式,短期内很爽,但一旦对话渐入复杂场景,模型很容易迷失,生成的参数东拼西凑。工具越具体,模型的调用准确率越高。
第三,一定要做完整的日志审计。每次工具调用、SQL语句、参数集合、执行耗时、结果行数,全部记录到独立的日志表里。这样无论是排查问题还是追溯数据泄露风险,都有据可查。我在生产环境给所有Agent查询请求都加了一个requestId,整个调用链全程关联。
第四,考虑加一层简单的权限控制。哪怕是内部工具,也要防止低权限用户通过自然语言诱导Agent查询敏感数据。我实现了一个很粗的权限方案:在系统提示词里告知Agent不同角色的可见字段范围,虽然不算严格安全,但能挡住大部分越权查询。真正的强隔离还是应该在数据库层面做视图。
从Web开发者到AI Agent开发者,其实并没有一条不可逾越的鸿沟。你需要的只是把已有的后端能力换一种方式组织起来:把接口调用理解成工具,把数据库操作理解成工具,把业务规则理解成工具,然后让一个擅长理解意图的模型来做调度。今天这套电商查询Agent,你完全可以自己动手改造一下,加上订单统计、库存预警、客户分群等功能,把它变成你日常工作和面试里的亮眼项目。