Files
AgentLab/backend/app/tools/datasource.py
T
ThearchyHelios 71561a0133 探查失败不再伪装成"还没探查":把实情和下一步交给 agent
从 run 554a0f92 查出来的。那次问「userID=2 是谁」,9 次工具调用花掉 6 次、
前 29 秒没碰到一点真数据:

    + 2.1s  db_schema            → "还没有探查过结构,请在设置页里执行一次"
    + 4.9s  db_schema ×7 个猜的表名 → "没有 user / users / member / …"
    + 7.7s  SELECT 1             → 不信任前面的结果,先探一下连通性
    +12.8s  information_schema.tables   ┐
    +18.3s  information_schema.tables   ├ 自己把 schema 重建一遍
    +21.5s  information_schema.columns  ┘
    +29.2s  终于开始查真数据

根因:shop 的探查**失败过**(Lost connection to MySQL server during query),
但失败被存成了一个"成功但没表"的缓存,schema_synced_at 照常盖戳:

    {"tables": {}, "total": 0,
     "error": "OperationalError: (2013, 'Lost connection...')",
     "available_schemas": ["prod_shop", "shop_dev", "toolsdb", …10 个]}

于是三处同时说假话:界面显示"结构未探查"、工具对 agent 说"还没有探查过"、
而那 10 个候选 schema 就躺在缓存里没人交出去。**用户明明点过探查,是超时了。**

**分清"还没探查"和"探查失败了"。** introspect 的结果加 failed 标记;
失败照样把缓存存下来(候选是下一步的线索),但不盖"已同步"的戳;
界面把红字和候选列出来;探查按钮的提示也分清是超时还是 schema 填错。

**给 agent 一个它做得到的下一步。** 以前那句"请在设置页里执行一次结构探查"
——agent 进不了设置页。现在换成实情 + 候选 schema + "不要猜表名,直接查
information_schema / sqlite_master"。那条自救路线 agent 最后也走通了,
只是自己摸索了三轮才找到,直接告诉它就行。

describe_table 同样:缓存空的时候说"数据源里没有 user"是在误导,真相是我们
什么都不知道。那次一口气问了 7 个表名,收到 7 条一模一样的
"没有 X。现有的对象:(还没探查过结构)",白花一整步。

顺带把逐表反射换成 SQLAlchemy 2.0 的 get_multi_*,但**别指望它治超时**:
实测 60 张表逐表 121 次往返、批量 122 次——反射缓存本来就把 get_columns 和
get_pk_constraint 合并了,而 MySQL 方言压根没实现 get_multi_*,落到基类还是
每表一次 SHOW CREATE TABLE。换它是因为这是 2.0 的正经接口、少三层
try/except,不是因为它快。shop 那次超时多半是偶发的,所以真正的对策
不在那儿,在"失败要如实记成失败"。

重新探查了一次 shop:153 张表、38 秒、成功。之前 agent 要花 6 次调用、
29 秒拼出来的东西,现在工具描述第一眼就给了。

验证:后端 269 passed(test_datasource.py 新增 4 条,守"失败不能被说成没探查过"
和"给 agent 的下一步它做得到");tsc 干净;三个 check 不回归。
2026-09-20 18:05:11 +08:00

192 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把数据源变成 agent 能调的工具。
每个启用的数据源生成一对工具,而不是"一个通用工具带 datasource 参数":
- 工具名自带语义(`db_query__sales`),模型不会把查销售库的 SQL 发给财务库
- 工具 description 里直接塞该库的表清单,模型一眼看到有什么可查,
省掉"先问有哪些表"那一轮
- 权限边界跟着工具走:只读源生成的工具就是只读的,dangerous 标记也各自独立
代价是工具数量随数据源线性增长。真到了几十个数据源的规模,再考虑按需装载。
"""
from __future__ import annotations
import json
from typing import Any
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import introspect
from app.data.engine import run_query
from app.data.guard import QueryLimits, SqlRejected, is_write
from app.db.models import DataSource
from app.tools.registry import ToolContext
QUERY_PREFIX = "db_query__"
SCHEMA_PREFIX = "db_schema__"
class _QueryArgs(BaseModel):
sql: str = Field(description="要执行的 SQL,一次一条")
limit: int | None = Field(default=None, description="最多返回多少行,默认 1000")
class _SchemaArgs(BaseModel):
# 一次能问好几张。串行跑工具时一次调用就是一步,逐张问 6 张表就是 6 步——
# 实测一次 8 步的运行里 7 步花在这上面,最后只剩一步来真查数据(run dd9927e6)
table: str | list[str] | None = Field(
default=None,
description='表名。可以一次给多张:"user" 或 ["user","roles"];留空则列出所有表',
)
def tool_names(source: DataSource) -> list[str]:
return [f"{QUERY_PREFIX}{source.name}", f"{SCHEMA_PREFIX}{source.name}"]
def _no_schema_note(source: DataSource) -> str:
"""结构拿不到时,把实情和下一步一起交给 agent。
以前这里只说"结构尚未探查,请在设置页里执行一次"。两个问题:它可能是错的
(探查执行过了,是超时失败),而且它给的下一步 agent 根本做不到——agent
进不了设置页。于是它只能自己摸:run 554a0f92 里先猜了 7 个表名、又发了
一条 SELECT 1 试连通性、再用三条 information_schema 把 schema 重建一遍,
9 次工具调用花掉 6 次、29 秒之后才碰到真正要查的数据。
那条自救路线是对的,只是它自己摸索了三轮才找到。直接告诉它。
"""
cache = source.schema_cache or {}
note = f" {introspect.why_empty(cache)}。"
candidates = cache.get("available_schemas") or []
if candidates:
# 这几个候选一直在缓存里躺着,只是以前没人交给 agent
note += (
f"当前连的库是「{source.database or '未指定'}」,"
f"这台服务器上还有这些库可选:{'、'.join(candidates[:10])}。"
)
note += (
"**不要猜表名**——直接查数据字典自己确认,"
"例如 information_schema.tables / information_schema.columns"
"(SQLite 则是 sqlite_master),一条带 WHERE 的查询就能拿到表和字段。"
)
return note
def _query_description(source: DataSource) -> str:
tables = introspect.table_names(source)
head = f"在数据源「{source.name}」上执行 SQL 查询。"
if source.description:
head += f"{source.description}。"
head += "只读。" if source.readonly else "可写(写操作需要人工审批)。"
if tables:
listed = "、".join(tables[:25])
more = f" 等 {len(tables)} 张表" if len(tables) > 25 else ""
head += f" 可用表:{listed}{more}。"
head += " 字段不确定时先查结构,不要猜字段名。"
else:
head += _no_schema_note(source)
return head
async def build_datasource_tools(
names: list[str], ctx: ToolContext, session: AsyncSession
) -> list[StructuredTool]:
"""按工具名反查数据源并构造工具。build_tools 的第四个来源。"""
wanted: dict[str, list[str]] = {}
for name in names:
for prefix in (QUERY_PREFIX, SCHEMA_PREFIX):
if name.startswith(prefix):
wanted.setdefault(name[len(prefix):], []).append(prefix)
if not wanted:
return []
rows = list((
await session.execute(
select(DataSource).where(
DataSource.name.in_(list(wanted)), DataSource.enabled.is_(True)
)
)
).scalars())
tools: list[StructuredTool] = []
for row in rows:
for prefix in wanted.get(row.name, []):
if prefix == QUERY_PREFIX:
tools.append(_make_query_tool(row, ctx))
else:
tools.append(_make_schema_tool(row))
return tools
def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool:
async def _run(sql: str, limit: int | None = None) -> str:
limits = QueryLimits(max_rows=int(limit)) if limit else QueryLimits()
try:
result = await run_query(source, sql, limits=limits)
except SqlRejected as e:
# 被守卫拒掉要把原因原样交回模型——它据此改写 SQL 重试,
# 给一句笼统的"失败了"只会让它瞎猜
return f"SQL 被拒绝:{e}"
except Exception as e: # noqa: BLE001
return f"查询失败:{type(e).__name__}: {e}"
payload = result.to_payload()
payload["source"] = source.name
# 查询快照进工件库:出具体系的数字回指要能下钻到"这个数是哪条 SQL 查出来的"
try:
from app.core.artifact_store import put_json
payload["artifact"] = await put_json(
payload, kind="query_snapshot",
run_id=ctx.run_id or "", node_id=ctx.node_id or "",
)
except Exception: # noqa: BLE001 - 存不下不影响查询本身
pass
return json.dumps(payload, ensure_ascii=False, default=str)
return StructuredTool(
name=f"{QUERY_PREFIX}{source.name}",
description=_query_description(source),
args_schema=_QueryArgs,
coroutine=_run,
func=None,
)
def _make_schema_tool(source: DataSource) -> StructuredTool:
async def _run(table: str | list[str] | None = None) -> str:
wanted = [table] if isinstance(table, str) else list(table or [])
# 逗号分隔也认:"user, roles" 和 ["user","roles"] 是同一个意思,
# 而模型两种都会写。为此拒绝一次调用,纯属浪费一步
wanted = [t.strip() for one in wanted for t in str(one).split(",") if t.strip()]
if wanted:
return "\n\n".join(introspect.describe_table(source, t) for t in wanted)
tables = introspect.table_names(source)
if not tables:
# 把实情交出去,而不是让它去点一个它点不到的按钮
return f"数据源「{source.name}」的结构信息不可用。" + _no_schema_note(source)
return f"数据源「{source.name}」共 {len(tables)} 张表:\n" + "\n".join(
f" {n}" for n in tables
)
return StructuredTool(
name=f"{SCHEMA_PREFIX}{source.name}",
description=(
f"查看数据源「{source.name}」的表结构。"
"写 SQL 前先用它确认字段名。不传 table 则列出所有表;"
"**要看多张表就一次全传进来**(table 可以是数组),不要一张一张问。"
),
args_schema=_SchemaArgs,
coroutine=_run,
func=None,
)
def is_dangerous_call(source: DataSource, sql: str) -> bool:
"""这次调用要不要走人工审批:非只读源上的写操作要。"""
return not source.readonly and is_write(sql)