DeepSeek Tool Calling:让 AI 查询真实用户数据
结构化输出让模型返回可被程序读取的数据,但它仍然不知道应用数据库里有哪些用户。这一篇增加一个只读工具:根据用户 ID 查询公开资料,让模型根据真实查询结果回答
模型负责选择工具和生成参数,Python 负责验证与执行。数据库连接和查询代码始终留在应用后端,模型不会直接连接 PostgreSQL
一、工具调用的完整过程
text
用户问"ID 为 3 的用户叫什么"
→ 第一次模型请求:提出 get_user_by_id 调用,参数 user_id=3
→ Python 验证工具名与参数
→ 调用现有 Service 和 Repository 查询 PostgreSQL
→ 把查询结果发给模型
→ 第二次模型请求:根据结果组织答案
这与上一篇的结构化输出用途不同:任务计划直接作为结果返回;工具参数则是一个待执行请求,必须经过程序审核后才能产生业务操作
本文只有一个工具、一个调用批次,最多两次模型请求,先把这条链路完整接通
二、定义请求、响应和工具参数
在 Git Bash 创建模块:
bash
cd /d/code/ai-workspace-rebuild/backend # cd 切换到后端目录
touch app/schemas/assistant.py app/ai/tools.py # touch 创建数据模型和工具声明文件,已有内容不会清空
app/schemas/assistant.py:
python
from pydantic import BaseModel, ConfigDict, Field
class UserLookupArguments(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: int = Field(gt=0)
class AssistantRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
class AssistantResponse(BaseModel):
answer: str
工具参数要求 user_id 为正整数,并拒绝额外字段。Pydantic 默认模式可能转换兼容输入,例如数字字符串;如果业务需要严格拒绝这类输入,应明确启用严格校验
AssistantRequest 是浏览器发给后端的问题,UserLookupArguments 是模型生成的工具参数,二者来源不同,都需要检查
三、工具声明只是说明书
app/ai/tools.py:
python
from app.schemas.assistant import UserLookupArguments
GET_USER_BY_ID_TOOL = {
"type": "function",
"name": "get_user_by_id",
"description": "根据用户 ID 查询用户公开资料,不包含密码或密码哈希。",
"parameters": UserLookupArguments.model_json_schema(),
}
ASSISTANT_TOOLS = [GET_USER_BY_ID_TOOL]
name 是模型请求调用时使用的名称,description 帮助模型判断工具用途,parameters 描述允许的参数结构
这里没有把 Python 函数传给模型。工具名恰好与业务函数同名,也不会自动关联执行,程序还需要明确的调度逻辑
工具参数仍使用 Pydantic 导出 JSON Schema,避免把规则分别写在模型和声明字典里
四、用白名单执行工具
bash
touch app/ai/tool_dispatcher.py # 创建工具调度模块
app/ai/tool_dispatcher.py:
python
import json
from pydantic import ValidationError
from app.repositories.users import UserRepository
from app.schemas.assistant import UserLookupArguments
from app.services.chat import LLMServiceError
from app.services.users import UserNotFoundError, get_user_by_id
def execute_tool(
name: str,
arguments: str,
repository: UserRepository,
) -> str:
if name != "get_user_by_id":
raise LLMServiceError("AI requested an unsupported tool")
try:
parsed = UserLookupArguments.model_validate_json(arguments)
except ValidationError as exc:
raise LLMServiceError("AI returned invalid tool arguments") from exc
try:
user = get_user_by_id(parsed.user_id, repository)
except UserNotFoundError:
return json.dumps({"error": "User not found"})
return user.model_dump_json()
工具名和 arguments 都是模型输出,不能直接信任。先精确匹配白名单,再解析和校验 JSON 字符串,通过后才调用代码中的 get_user_by_id
不能使用 eval 或按模型给出的名字任意查找并执行全局函数。支持哪些功能,应由程序显式决定
已有查询 Service 返回 UserResponse,只包含公开字段,因此模型接收不到 password 或 hashed_password。model_dump_json 返回 JSON 字符串,与返回字典的 model_dump 不同
用户不存在是正常查询结果,返回错误对象让模型解释即可;未知工具或无效参数则停止执行并抛出项目异常
白名单限定功能范围,不决定用户是否有权查看某条数据。当前示例尚未实现按用户或租户过滤查询范围
五、为什么有两次模型响应
第一次请求结束时,模型可能只返回调用要求:
json
{
"type": "function_call",
"name": "get_user_by_id",
"arguments": "{\"user_id\": 3}",
"call_id": "call_123"
}
此时数据库还没被查询。Python 查询得到资料后,需要把结果发回去,模型才能基于实际数据回答
first_response 用于决定调用,final_response 用于消化查询结果。用户只问普通问题且模型没有请求工具时,第一次结果就直接作为答案返回
如果产品只需要显示查询 JSON,可以省掉第二次生成;本文希望得到自然语言答案,因此保留第二次请求
六、串联模型请求和工具执行
bash
touch app/services/assistant.py # 创建工具助手业务模块
app/services/assistant.py:
python
from openai import APIError
from app.ai.client import DEEPSEEK_MODEL, deepseek_client
from app.ai.tool_dispatcher import execute_tool
from app.ai.tools import ASSISTANT_TOOLS
from app.repositories.users import UserRepository
from app.schemas.assistant import AssistantResponse
from app.services.chat import LLMServiceError
ASSISTANT_INSTRUCTIONS = """
你是 AI Workspace 助手,请使用中文回答。
用户按数字 ID 查询用户资料时,使用 get_user_by_id 工具。
只能根据工具结果回答用户资料,不得编造。
"""
def answer_with_tools(
message: str,
repository: UserRepository,
) -> AssistantResponse:
conversation: list[dict[str, str]] = [
{"role": "user", "content": message}
]
try:
first_response = deepseek_client.responses.create(
model=DEEPSEEK_MODEL,
instructions=ASSISTANT_INSTRUCTIONS,
input=conversation,
tools=ASSISTANT_TOOLS,
tool_choice="auto",
reasoning={"effort": "none"},
)
if first_response.status != "completed":
raise LLMServiceError("AI assistant response did not complete")
tool_was_called = False
for item in first_response.output:
if item.type != "function_call":
continue
tool_was_called = True
conversation.append({
"type": "function_call",
"call_id": item.call_id,
"name": item.name,
"arguments": item.arguments,
})
tool_output = execute_tool(
item.name,
item.arguments,
repository,
)
conversation.append({
"type": "function_call_output",
"call_id": item.call_id,
"output": tool_output,
})
if not tool_was_called:
return AssistantResponse(answer=first_response.output_text)
final_response = deepseek_client.responses.create(
model=DEEPSEEK_MODEL,
instructions=ASSISTANT_INSTRUCTIONS,
input=conversation,
reasoning={"effort": "none"},
)
if final_response.status != "completed":
raise LLMServiceError("AI assistant response did not complete")
except APIError as exc:
raise LLMServiceError("AI assistant request failed") from exc
return AssistantResponse(answer=final_response.output_text)
tool_choice=auto 允许模型自行决定是否调用。第二次请求没有再提供工具,因此当前流程不会进入反复规划和执行的循环
reasoning.effort=none 关闭思考模式,先聚焦工具调用协议。该配置对应当前 DeepSeek Responses API,不能不加核对地照搬到其他模型服务。DeepSeek 思考模式说明
程序遍历首轮输出中的 function_call,同一批出现多个调用时会依次执行,但只允许已声明的查询工具。这不是支持任意多步任务的 Agent 循环
七、call_id 关联调用和结果
第二次 input 不只包含查询结果,还包含原始问题、工具调用记录和结果记录。其中这两个对象必须使用同一个 call_id:
python
{
"type": "function_call",
"call_id": item.call_id,
"name": item.name,
"arguments": item.arguments,
}
python
{
"type": "function_call_output",
"call_id": item.call_id,
"output": tool_output,
}
工具名只能说明调用哪个功能,同一个工具也可能被调用多次,call_id 才能精确标记结果属于哪次请求
每次 answer_with_tools 都建立新的 conversation,包含一次问题及其工具往返,没有接入聊天区域的历史消息
八、提供受认证保护的接口
bash
touch app/routers/assistant.py # 创建助手路由模块
app/routers/assistant.py:
python
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_session
from app.dependencies.auth import get_current_user
from app.repositories.users import UserRepository
from app.schemas.assistant import AssistantRequest, AssistantResponse
from app.schemas.users import UserResponse
from app.services.assistant import answer_with_tools
from app.services.chat import LLMServiceError
router = APIRouter(prefix="/assistant", tags=["assistant"])
@router.post("", response_model=AssistantResponse)
def ask_assistant(
request: AssistantRequest,
session: Annotated[Session, Depends(get_session)],
current_user: Annotated[UserResponse, Depends(get_current_user)],
) -> AssistantResponse:
repository = UserRepository(session)
try:
return answer_with_tools(request.message, repository)
except LLMServiceError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
认证依赖检查身份,Session 交给 Repository 执行实际查询。在 main.py 增加导入和注册:
python
from app.routers.assistant import router as assistant_router
# 放在 app 创建之后,与已有路由一起注册
app.include_router(assistant_router)
接口成功返回 200;认证失败返回 401;参数模型不合法或不支持的工具调用转换为 LLMServiceError,最终返回 502
工具查询到"没有用户"则作为正常结果交回模型,不直接把这次助手请求转换为 404
九、前端只提交问题
创建请求模块:
bash
cd /d/code/ai-workspace-rebuild/frontend # 切换到前端目录
touch src/api/assistant.ts # 创建工具助手请求模块
src/api/assistant.ts:
ts
import { readErrorMessage } from "./users";
export async function askAssistant(
message: string,
accessToken: string,
): Promise<string> {
const response = await fetch("http://127.0.0.1:8000/assistant", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
const body: unknown = await response.json();
if (
typeof body !== "object" ||
body === null ||
!("answer" in body) ||
typeof body.answer !== "string"
) {
throw new Error("Invalid assistant response");
}
return body.answer;
}
前端不直接传入工具名或数据库查询语句,只提交问题和登录令牌。工具选择及执行都留在后端
十、接入页面状态和展示
在 App.vue 导入:
ts
import { askAssistant } from "./api/assistant";
沿用已有的 ref 和登录状态,增加:
ts
const assistantInput = ref("");
const assistantAnswer = ref("");
const assistantError = ref("");
const isAskingAssistant = ref(false);
async function submitAssistant() {
if (isAskingAssistant.value) return;
assistantError.value = "";
if (!accessToken.value) {
assistantError.value = "请先登录";
return;
}
const message = assistantInput.value.trim();
if (!message) {
assistantError.value = "请输入问题";
return;
}
isAskingAssistant.value = true;
assistantAnswer.value = "";
try {
assistantAnswer.value = await askAssistant(message, accessToken.value);
} catch (err: unknown) {
assistantError.value = err instanceof Error ? err.message : "请求失败";
} finally {
isAskingAssistant.value = false;
}
}
在已有 main 中增加独立区域:
vue
<section>
<h2>工具助手</h2>
<form @submit.prevent="submitAssistant">
<textarea
v-model="assistantInput"
placeholder="例如:查询 ID 为 1 的用户名和昵称"
rows="3"
maxlength="2000"
:disabled="isAskingAssistant || !currentUser"
required
></textarea>
<button type="submit" :disabled="isAskingAssistant || !currentUser">
{{ isAskingAssistant ? "查询中..." : "询问助手" }}
</button>
</form>
<p v-if="assistantError">{{ assistantError }}</p>
<p class="chat-answer">{{ assistantAnswer }}</p>
</section>
沿用此前 chat-answer 的 white-space: pre-wrap 样式保留换行。登录函数的开头也要增加新请求状态判断,并清空旧助手结果:
ts
if (
isLoggingIn.value ||
isSending.value ||
isPlanning.value ||
isAskingAssistant.value
) return;
assistantAnswer.value = "";
assistantError.value = "";
避免生成中切换账号,或新登录后继续展示上一个账号的查询回答
十一、用真实数据检查回答
登录后先查询一个数据库中确定存在的 ID:
json
{
"message": "请查询 ID 为 1 的用户,告诉我用户名和昵称"
}
将 ID 换成实际记录,比较模型回答与数据库字段。再查询一个不存在的正整数 ID,应说明没有找到用户,而不是补出一份资料
接口和页面都应覆盖存在、不存在两种情况。答案正确之外,还可以在调试时查看首轮是否真的出现 function_call,避免仅凭一段听起来合理的回复推断工具执行成功
工具只读返回 UserResponse 中的公开字段,不会执行更新或删除。提示词"不编造"是行为要求,不是权限机制,也不能保证所有模型回复都正确
阶段结果
到这里,模型已能根据问题提出用户查询请求,后端通过工具名白名单和 Pydantic 参数校验,复用现有业务层读取真实数据库,再将结果交回模型生成回答。受认证保护的助手接口和前端展示已接通,存在与不存在用户的查询均已跑通
但当前完成的是一个受控的只读查询示例:
- 只声明一个工具,最多进行两次模型请求,没有多步骤工具循环
- 查询未增加按用户、角色或租户划分的数据访问权限
- 没有写入工具、敏感操作确认和工具执行审计
- 没有单独设置工具调用数量上限、调用预算和完整重试策略
- 助手没有跨请求会话历史,答案未做针对工具结果的独立事实校验
- 没有接入文档知识库,工具访问仍限于已编写的业务查询
下一篇实现最小 RAG 知识库:文档切块、生成向量、存入 PostgreSQL,再检索相关片段并交给模型回答