让大模型安全调用外部工具:参数校验、权限和超时控制
系列:Python + FastAPI 大模型应用基础(第 25 篇)
1. 工具调用是一条不可信输入链
模型生成的工具名和参数与普通用户输入一样不可信。安全执行器至少经过:
text
工具白名单 → 参数 Schema → 身份权限 → 业务规则
→ 超时/并发 → 幂等执行 → 审计结果
2. 定义调用上下文
python
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, Field
@dataclass(frozen=True)
class ExecutionContext:
request_id: str
user_id: str
tenant_id: str
permissions: frozenset[str]
class SendMessageArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
recipient_id: str = Field(pattern=r"^USER-[0-9]{1,12}$")
text: str = Field(min_length=1, max_length=1000)
idempotency_key: str = Field(min_length=16, max_length=100)
模型不提交 tenant_id 和 user_id,这两项只能来自登录会话。
3. 带超时的异步执行器
python
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
async def run_with_timeout(
operation: Callable[[], Awaitable[dict[str, Any]]],
timeout_seconds: float,
) -> dict[str, Any]:
if not 0 < timeout_seconds <= 30:
raise ValueError("工具超时必须在 0 到 30 秒之间")
try:
return await asyncio.wait_for(operation(), timeout_seconds)
except TimeoutError as exc:
raise RuntimeError("工具执行超时") from exc
async def send_message(
ctx: ExecutionContext,
args: SendMessageArgs,
) -> dict[str, Any]:
if "message:send" not in ctx.permissions:
raise PermissionError("无消息发送权限")
async def operation() -> dict[str, Any]:
# 示例不连接真实消息平台;生产实现需传递幂等键
await asyncio.sleep(0)
return {
"status": "accepted",
"recipient_id": args.recipient_id,
"request_id": ctx.request_id,
}
return await run_with_timeout(operation, timeout_seconds=3)
accepted 仅表示示例执行器接受请求,不代表真实消息送达。真实系统应区分接受、发送、送达和失败。
4. 幂等性防止重复副作用
python
class InMemoryIdempotencyStore:
"""演示存储;多进程生产环境应使用共享原子存储。"""
def __init__(self) -> None:
self._results: dict[str, dict[str, Any]] = {}
self._lock = asyncio.Lock()
async def execute_once(
self,
key: str,
action: Callable[[], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
async with self._lock:
if key in self._results:
return self._results[key]
result = await action()
self._results[key] = result
return result
真实幂等设计还需处理"外部调用成功但本地记录失败"的不确定状态,常用外部幂等键、事务消息或人工对账。
5. 可复验测试
python
async def test_permission_denied() -> None:
ctx = ExecutionContext("r1", "u1", "t1", frozenset())
args = SendMessageArgs(
recipient_id="USER-1",
text="测试",
idempotency_key="1234567890abcdef",
)
try:
await send_message(ctx, args)
except PermissionError:
pass
else:
raise AssertionError("缺少权限时必须拒绝")
async def test_idempotent_execution() -> None:
store = InMemoryIdempotencyStore()
calls = 0
async def action() -> dict[str, Any]:
nonlocal calls
calls += 1
return {"ok": True}
await store.execute_once("key", action)
await store.execute_once("key", action)
assert calls == 1
6. 对抗性审查
- 禁止任意 Shell、任意 SQL 和任意 URL 工具;
- 写操作默认需要更高权限或人工确认;
- 幂等键不能由模型随意重复使用;
- 工具输出限制大小,防止挤占上下文;
- 请求超时后不能假定外部操作一定失败;
- 工具凭证采用短期、最小范围授权;
- 审计日志不保存消息正文等敏感数据。
7. 总结
安全工具调用不是在 Prompt 里写几句禁止事项,而是把模型限制在确定性的执行器之后,并为每个副作用设计权限、超时和幂等语义。