前端手摸手跑路之 AI 应用开发(十一)

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,再检索相关片段并交给模型回答

相关推荐
用户9888321871741 小时前
从“限流数字”到可维护策略:ASP.NET Core 固定窗口限流的落地边界
后端
创了么OPC1 小时前
小程序能收款了,对公户还没开?独立开发者的交付闭环踩坑②
前端·微信小程序
IT_陈寒1 小时前
Vue动态组件缓存的坑你踩过了吗?
前端·人工智能·后端
寂寞狼牙冷1 小时前
封装fetch替换axios
前端
用户461169657281 小时前
网站 HTTPS 不稳定?证书排查指南
前端
雾行0x001 小时前
粗排:如何在毫秒级筛出值得精排的内容?
后端·架构
D121382 小时前
从零用 C++17 写一个 API 网关:线程模型、控制面分离,以及三个真实 Bug
后端
Scene2162 小时前
AgentScope Java 进阶:2.如何精准定义问题类型与处理“不确定性”?
后端
用户345966815212 小时前
在嵌入式 Linux 摄像头上跑 WebRTC:metaRTC 8.0 的 8 个坑与解法
后端