返回文章
数据与知识·约 13 分钟·Kain

Agent 接入企业内部数据查询分析:从痛点到工程闭环

从自然语言查询走到企业数据工程:语义、SQL、安全、工具与执行反馈如何接起来。

Agent 接入企业内部数据查询分析:从痛点到工程闭环的封面

企业内部的数据查询分析,很容易被简化成“自然语言转 SQL”。用户问“最近一周的退货情况,按商品、尺码分组汇总退货金额和退货数量,按照金额倒排序”,Agent 生成 SQL,查库,返回表格,表面看链路就完成了。

但真实系统里的问题通常不在“SQL 能不能执行”,而在“结果是不是符合业务预期”。我们在零售 POS 助手里遇到过一个典型问题:用户明确要求退货金额和退货数量,系统只返回了数量;用户要求按商品、尺码分组,结果只按商品分组,尺码丢了。接口成功,数据库也返回了数据,但业务结果是错的。

这个问题暴露出一个更底层的矛盾:企业数据查询 Agent 不能只靠模型临场写 SQL。它要同时处理业务口径、权限边界、多轮状态、查询质量和后续复盘。模型可以理解用户想要什么,但不能把原始数据库、权限判断和业务口径都交给模型自由发挥。

这里先明确架构边界。本文讨论的是 Agent OS 能力层里的 Data Query 插件,不是把 Agent OS 的通用 Planner 定义成报表设计器。Agent OS 平台层仍然负责通用的任务规划、能力选择、Session、TaskState、Policy、Audit、Observability 和插件治理;数据查询只是其中一个重点能力场景。

所以在 Data Query 插件内部,我们把方案从“模型生成 SQL”调整为“Query Planner 生成查询规格,系统受控编译 SQL”。这个设计更接近 Kibana 报表:CTE 像一个受控索引,YAML 像索引字段和指标目录,Query Planner 像查询规格生成器,SQL builder 像查询编译器,执行链路负责权限、执行、观测和反馈。

下面用一个完整问题贯穿这条链路:

最近一周的退货情况,按商品,尺码分组汇总退货金额和退货数量,按照金额倒排序

下面所有 SQL、YAML、字段名、表名、状态值和门店号都使用脱敏示例命名,只表达工程结构和链路关系,不对应真实生产库命名。

在 2026-05-03 执行时,“最近一周”会被解析为 2026-04-27 到 2026-05-03。这个例子覆盖了多个容易出错的点:多指标、多维度、指标排序、时间范围、门店权限和质量追踪。

第一个节点是 Data Query 插件内部的 Query Planner。它不直接输出 SQL,而是输出一个结构化查询规格。

这个节点解决的问题是“用户说的是自然语言,但执行层需要稳定契约”。如果 Agent OS 只选择了一个 capability,例如 sales.return_analysis,后面的工具仍然要重新猜指标、维度和排序,问题会再次分散到工具内部。Query Planner 需要在这个 capability 内,把用户问题拆成稳定的 ReportSpec。

下面的 JSON 展示的是当前实现里的统一 PlannerDecision 载体:selected_capabilities 属于 Agent OS 层面的能力选择,report_spec 属于 Data Query 插件内部的查询规格。工程上可以放在同一个结构里传递,但架构表达上要区分这两层。

这个问题对应的计划应该接近下面这样:

{
  "task_type": "new_task",
  "resolved_intent": "退货数据分析",
  "selected_capabilities": [
    {
      "type": "tool",
      "name": "sales.return_analysis",
      "priority": 100
    }
  ],
  "slot_update": {
    "task_relation": "new_task",
    "selected_capability": "sales.return_analysis",
    "status": "ready",
    "slot_updates": {
      "report_spec": {
        "source": "return_topic",
        "capability": "sales.return_analysis",
        "metrics": ["退货金额", "退货数量"],
        "dimensions": ["product", "size_code"],
        "sort": [
          {
            "field": "退货金额",
            "order": "desc"
          }
        ],
        "time_range": {
          "start_date": "2026-04-27",
          "end_date": "2026-05-03",
          "keyword": "最近一周",
          "days": 7
        },
        "limit": 20
      },
      "metric_type": "退货金额,退货数量",
      "group_by": "product,size_code",
      "sort_by": "退货金额",
      "sort_order": "desc"
    }
  },
  "need_clarification": false
}

这里有几个关键点。metrics 必须保留两个指标,不能把“金额和数量”压缩成一个字段;dimensions 必须保留 product 和 size_code 两个分组,不能只留下商品;排序字段保留用户原话里的“退货金额”,后面由元数据解析到受控指标;兼容字段 metric_type、group_by、sort_by 只是为了旧链路兼容,真正的主契约是 report_spec。

第二个节点是 CTE 受控数据面。它解决的问题是“模型不应该直接面对原始业务库”。

退货分析底层涉及 biz_return_header 和 biz_return_line。如果让模型直接查原始表,它需要自己判断主明细 join、有效状态、退货业务类型、统计日期、退货金额字段和门店权限。任何一个判断错了,结果都会偏。

当前退货数据面通过 resources/ctes/return_topic.sql 固化:

WITH return_topic_cte AS (
    SELECT
        h.return_doc_id,
        h.store_id,
        DATE(h.return_date) AS return_date,
        h.doc_status,
        h.return_type,
        l.product_code,
        COALESCE(NULLIF(l.product_name, ''), l.product_code) AS product_name,
        l.size_code,
        l.brand_code,
        l.brand_name,
        l.category_code,
        l.return_quantity,
        COALESCE(l.return_net_amount, l.fallback_amount, 0) AS return_net_amount
    FROM biz_return_header h
    INNER JOIN biz_return_line l ON l.return_doc_id = h.return_doc_id
    WHERE h.store_id IN :store_scope
        AND DATE(h.return_date) BETWEEN :start_date AND :end_date
        AND h.return_type = 'RETURN'
        AND h.doc_status IN ('COMPLETED', 'SHIPPED', 'CLOSED')
)

这个 CTE 里面已经固定了几类业务规则:只看授权门店 store_scope,时间条件由服务端参数注入,只看 return_type = 'RETURN' 的退货,只统计脱敏示例中的有效状态 COMPLETED/SHIPPED/CLOSED,退货金额统一取 return_net_amount,缺失时回退到 fallback_amount。这就是“受控数据面”的意义:Query Planner 生成 ReportSpec 时看到的是 return_topic_cte,不是整个 POS 原始库。

“尺码分组丢失”的问题也落在这个节点上。早期 CTE 如果没有暴露 size_code,Query Planner 即使理解了用户要按尺码分组,SQL builder 也不能合法生成 GROUP BY size_code。修复方式不是让模型猜 biz_return_line.size_code,而是把 size_code 加入 CTE 输出字段和字段目录。这样尺码才真正成为系统支持的分析维度。

第三个节点是 CTE YAML 元数据。它解决的问题是“字段存在,但系统还需要知道字段的业务语义”。

return_topic.yaml 里会把 CTE 输出字段、指标、维度和 capability 绑定起来。下面是和这个案例直接相关的配置节选:

name: return_topic
label: "退货换货"
description: "退换货单主表+明细表联合查询,支持按日期、商品维度分析退货金额、退货数量、退货单数"

columns:
  - name: return_doc_id
    description: "退换货单号"
    type: string
  - name: store_id
    description: "门店编号"
    type: string
  - name: return_date
    description: "退换货日期"
    type: date
  - name: doc_status
    description: "退换货状态"
    type: string
  - name: return_type
    description: "退换货业务类型"
    type: string
  - name: product_code
    description: "商品编号/货号"
    type: string
  - name: product_name
    description: "商品名称"
    type: string
  - name: size_code
    description: "尺码"
    type: string
  - name: brand_code
    description: "品牌编码"
    type: string
  - name: brand_name
    description: "品牌名称"
    type: string
  - name: category_code
    description: "品类编码"
    type: string
  - name: return_quantity
    description: "退货数量"
    type: integer
  - name: return_net_amount
    description: "实际退货金额"
    type: decimal

required_params: [store_scope, start_date, end_date]
auto_dimensions: true

capabilities:
  sales.return_analysis: true

metrics:
  return_amount:
    expr: "SUM(return_net_amount)"
    label: "退货金额"
  return_qty:
    expr: "SUM(return_quantity)"
    label: "退货数量"
  return_order_count:
    expr: "COUNT(DISTINCT return_doc_id)"
    label: "退货单数"

dimensions:
  date:
    select_exprs: ["DATE(return_date) AS return_date"]
    group_exprs: ["DATE(return_date)"]
    order_exprs: ["DATE(return_date)"]
    display_mapping: {return_date: "日期"}
    capabilities:
      sales.return_analysis: true
  product:
    select_exprs:
      - "product_code AS product_code"
      - "product_name AS product_name"
      - "MAX(brand_code) AS brand_code"
      - "MAX(brand_name) AS brand_name"
      - "MAX(category_code) AS category_code"
    group_exprs: ["product_code", "product_name"]
    display_mapping:
      product_code: "商品编码"
      product_name: "商品名称"
      brand_name: "品牌"
      category_code: "品类"
    capabilities:
      sales.return_analysis: true

这个配置回答了几个问题。用户说“退货金额”,系统知道它不是某个明细字段原样展示,而是 SUM(return_net_amount);用户说“退货数量”,系统知道它是 SUM(return_quantity);用户说“按商品”,系统知道商品维度需要同时选择商品编码和商品名称,并按这两个表达式分组;用户说“按尺码”,因为 auto_dimensions: true 且 size_code 是可分组字段,系统会自动派生 size_code AS size_code 和 GROUP BY size_code。

另一个场景也能说明 YAML 的价值。用户问:

按商品品牌信息汇总退货情况,按照金额倒排序

Query Planner 可以把“商品品牌信息”解析成 brand_name,把“金额倒排序”解析成 sort.field = 退货金额。SQL builder 最终会生成:

SELECT
    brand_name AS brand_name,
    SUM(return_net_amount) AS return_amount
FROM return_topic_cte
GROUP BY brand_name
ORDER BY return_amount DESC

这里没有为“品牌退货汇总”单独写一个工具,也没有在注册信息里维护一条“品牌金额倒序”的固定报表。YAML 只维护数据面的字段、指标和维度语义,具体报表组合由 Query Planner 和 builder 运行时完成。

第四个节点是 SQL builder。它解决的问题是“Query Planner 可以生成查询规格,但 SQL 必须由系统受控编译”。

对于主案例,builder 会先做一次语义解析:

metrics:    ["退货金额", "退货数量"] -> ["return_amount", "return_qty"]
dimensions: ["product", "size_code"] -> ["product", "size_code"]
sort:       {"field": "退货金额", "order": "desc"} -> ORDER BY return_amount DESC
limit:      20 -> LIMIT :limit

然后生成 outer SQL,也就是只运行在 return_topic_cte 之上的外层报表 SQL:

SELECT
    product_code AS product_code,
    product_name AS product_name,
    MAX(brand_code) AS brand_code,
    MAX(brand_name) AS brand_name,
    MAX(category_code) AS category_code,
    size_code AS size_code,
    SUM(return_net_amount) AS return_amount,
    SUM(return_quantity) AS return_qty
FROM return_topic_cte
GROUP BY product_code, product_name, size_code
ORDER BY return_amount DESC
LIMIT :limit

最终执行 SQL 是 CTE 模板加 outer SQL:

WITH return_topic_cte AS (
    SELECT
        h.return_doc_id,
        h.store_id,
        DATE(h.return_date) AS return_date,
        h.doc_status,
        h.return_type,
        l.product_code,
        COALESCE(NULLIF(l.product_name, ''), l.product_code) AS product_name,
        l.size_code,
        l.brand_code,
        l.brand_name,
        l.category_code,
        l.return_quantity,
        COALESCE(l.return_net_amount, l.fallback_amount, 0) AS return_net_amount
    FROM biz_return_header h
    INNER JOIN biz_return_line l ON l.return_doc_id = h.return_doc_id
    WHERE h.store_id IN :store_scope
        AND DATE(h.return_date) BETWEEN :start_date AND :end_date
        AND h.return_type = 'RETURN'
        AND h.doc_status IN ('COMPLETED', 'SHIPPED', 'CLOSED')
)
SELECT
    product_code AS product_code,
    product_name AS product_name,
    MAX(brand_code) AS brand_code,
    MAX(brand_name) AS brand_name,
    MAX(category_code) AS category_code,
    size_code AS size_code,
    SUM(return_net_amount) AS return_amount,
    SUM(return_quantity) AS return_qty
FROM return_topic_cte
GROUP BY product_code, product_name, size_code
ORDER BY return_amount DESC
LIMIT :limit

参数由服务端注入:

{
  "store_scope": ["STORE_A", "STORE_B"],
  "start_date": "2026-04-27",
  "end_date": "2026-05-03",
  "exclude_internal": true,
  "limit": 20
}

这一步有两个边界很重要。第一,模型没有机会把 store_scope 改成自己想查的门店,权限来自 PermissionContext。第二,outer SQL 会做字段白名单校验,只允许引用 CTE 输出字段、指标别名和维度别名。如果用户要求一个数据面没有暴露的字段,系统应该明确失败或生成 feedback case,而不是编译一个看似能跑但口径不明的 SQL。

第五个节点是排序语义。它解决的问题是“用户的排序表达有很多种,但落到 SQL 必须可解释”。

同样是退货分析,用户可能说:

最近一周的退货情况,按照时间倒排序

Query Planner 应该把它理解成按日期维度展示,并按时间倒序:

{
  "source": "return_topic",
  "capability": "sales.return_analysis",
  "metrics": ["退货金额", "退货数量", "退货单数"],
  "dimensions": ["date"],
  "sort": [
    {
      "field": "时间",
      "order": "desc"
    }
  ],
  "time_range": {
    "start_date": "2026-04-27",
    "end_date": "2026-05-03",
    "keyword": "最近一周",
    "days": 7
  }
}

builder 会把“时间”解析到日期维度的展示别名 return_date,输出:

SELECT
    DATE(return_date) AS return_date,
    SUM(return_net_amount) AS return_amount,
    SUM(return_quantity) AS return_qty,
    COUNT(DISTINCT return_doc_id) AS return_order_count
FROM return_topic_cte
GROUP BY DATE(return_date)
ORDER BY return_date DESC

如果用户改成“按照退货金额倒排序”,同一个排序结构会解析到指标 return_amount,SQL 就变成 ORDER BY return_amount DESC。这也是为什么排序逻辑不能散落在各个业务工具里:排序本质上是 ReportSpec 的一部分,builder 只负责把受控语义编译成 SQL。

第六个节点是多轮状态。它解决的问题是“用户不会每一轮都把所有条件说完整”。

一个常见对话是:

用户:最近一周的退货情况,按商品,尺码分组汇总退货金额和退货数量
助手:返回表格
用户:按金额倒排序

第二轮不是一个全新查询,它是对上一轮退货报表的 revision。task_state 里需要保留上一轮的 capability、source、metrics、dimensions、time_range 和结果上下文。Query Planner 在第二轮只补充排序槽位:

{
  "task_type": "revision",
  "selected_capabilities": [
    {
      "type": "tool",
      "name": "sales.return_analysis",
      "priority": 100
    }
  ],
  "slot_update": {
    "task_relation": "revision",
    "selected_capability": "sales.return_analysis",
    "status": "ready",
    "slot_updates": {
      "report_spec": {
        "source": "return_topic",
        "capability": "sales.return_analysis",
        "metrics": ["退货金额", "退货数量"],
        "dimensions": ["product", "size_code"],
        "sort": [
          {
            "field": "退货金额",
            "order": "desc"
          }
        ],
        "time_range": {
          "start_date": "2026-04-27",
          "end_date": "2026-05-03",
          "keyword": "最近一周",
          "days": 7
        }
      }
    }
  }
}

这里的关键不是“记住聊天内容”,而是保留结构化任务态。没有结构化状态,第二轮“按金额倒排序”很容易被误判成一个缺少时间范围和指标的新任务;有了任务态,Executor 可以把它合并回上一轮报表规格,再重新编译 SQL。

第七个节点是权限和 guardrails。它解决的问题是“企业数据查询不能依赖模型自觉守规矩”。

假设用户说:

帮我查 STORE_X 门店最近一周的退货金额

如果当前登录用户的 PermissionContext.store_scope 只有 ["STORE_A", "STORE_B"],执行层不能因为用户提到了 STORE_X 就扩大查询范围。服务端注入的参数仍然只能是授权范围:

{
  "store_scope": ["STORE_A", "STORE_B"],
  "start_date": "2026-04-27",
  "end_date": "2026-05-03"
}

如果系统支持门店过滤,还必须在权限范围内求交集;如果交集为空,就应该拒绝或提示无权限,而不是把用户输入的门店号直接拼进 SQL。SQL validator 还会限制只读查询、表白名单和外层字段白名单。内部日志可以记录 SQL 和参数,面向业务用户的最终回复只能展示业务结果,不能暴露 SQL、原始 JSON 或内部状态。

第八个节点是 query trace。它解决的问题是“查询执行成功,不代表质量合格”。

主案例执行后,trace 会记录这次查询的真实结构化结果。下面是一个脱敏后的 trace 形态:

{
  "trace_id": "trace_demo_001",
  "created_at": "2026-05-03T10:30:00+08:00",
  "trace_date": "2026-05-03",
  "request_id": "req_demo_001",
  "session_id": "session_001",
  "tenant_id": "t_001",
  "user_id": "u_001",
  "user_query": "最近一周的退货情况,按商品,尺码分组汇总退货金额和退货数量,按照金额倒排序",
  "capability": "sales.return_analysis",
  "source": "return_topic",
  "status": "success",
  "report_spec": {
    "source": "return_topic",
    "capability": "sales.return_analysis",
    "metrics": ["退货金额", "退货数量"],
    "dimensions": ["product", "size_code"],
    "sort": [
      {
        "field": "退货金额",
        "order": "desc"
      }
    ]
  },
  "resolved": {
    "metrics": ["return_amount", "return_qty"],
    "metric_labels": ["退货金额", "退货数量"],
    "dimensions": ["product", "size_code"],
    "sort": {
      "field": "return_amount",
      "order": "desc"
    },
    "display_columns": ["商品编码", "商品名称", "品牌", "品类", "尺码", "退货金额", "退货数量"]
  },
  "business_time_range": {
    "start_date": "2026-04-27",
    "end_date": "2026-05-03",
    "keyword": "最近一周",
    "days": 7
  },
  "row_count": 20,
  "quality_flags": [],
  "quality_status": "ok"
}

如果再次出现“用户要求两个指标但只解析出一个”“用户要求尺码分组但 resolved dimensions 里没有 size_code”“用户要求倒序但 resolved sort 是 asc”,自动质量规则会把 trace 标记为 needs_review 或 bad。

例如早期错误版本可能留下这样的 trace:

{
  "trace_id": "trace_bad_001",
  "user_query": "最近一周的退货情况,按商品,尺码分组汇总退货金额和退货数量",
  "capability": "sales.return_analysis",
  "source": "return_topic",
  "status": "success",
  "report_spec": {
    "metrics": ["退货数量"],
    "dimensions": ["product"]
  },
  "resolved": {
    "metrics": ["return_qty"],
    "metric_labels": ["退货数量"],
    "dimensions": ["product"]
  },
  "quality_flags": [
    "requested_metric_missing:return_amount",
    "requested_dimension_missing:size_code"
  ],
  "quality_status": "needs_review"
}

这个 trace 比单纯的用户抱怨更有价值,因为它能定位问题发生在哪一层:Query Planner 输出时丢了指标和维度,还是 builder 解析时丢了,或者 CTE 元数据根本没有暴露字段。

第九个节点是 feedback case。它解决的问题是“问题要能沉淀成可修复、可回归的质量样本”。

用户反馈“尺码分组丢了”后,系统可以把它绑定到上一条 trace:

{
  "case_id": "case_demo_001",
  "created_at": "2026-05-03T10:35:00+08:00",
  "case_date": "2026-05-03",
  "trace_id": "trace_bad_001",
  "source": "user_feedback",
  "category": "dimension_missing",
  "user_feedback": "尺码分组丢了,金额也没有汇总",
  "expected": {
    "metrics": ["return_amount", "return_qty"],
    "dimensions": ["product", "size_code"]
  },
  "actual": {
    "metrics": ["return_qty"],
    "dimensions": ["product"]
  },
  "quality_flags": [
    "requested_metric_missing:return_amount",
    "requested_dimension_missing:size_code"
  ],
  "status": "open",
  "fixed_commit": ""
}

后续修复时,工程动作可能是补 CTE 字段、补 YAML 维度、修 Query Planner 的结构化输出约束、修通用澄清边界、修 builder 解析,或者补状态合并。修完后把这个自然语言问题加入回归测试,case 关联修复 commit,状态改成 resolved。这样数据查询能力会形成正向循环,而不是靠聊天记录里零散的问题描述推动优化。

第十个节点是人工核对入口。它解决的问题是“质量样本要能被运营或工程人员批量查看”。

trace 和 case 都按日期切分:

data/traces/query_traces/YYYY-MM-DD.jsonl
data/feedback/feedback_cases/YYYY-MM-DD.jsonl

这里的 trace 日期是请求发生日期,不是用户查询的业务日期。自动生成的 feedback case 会跟随 trace 日期,人工提交的 feedback case 则记录提交日期并通过 trace_id 关联原始 trace。用户在 2026-05-03 问“最近一周”,trace 落到 2026-05-03.jsonl,业务时间范围保存在 business_time_range 里。这样线上排查按发生日期找日志,业务复核按 trace 里的业务日期看数据。

内部页面可以按日期、capability 和质量状态筛选:

/ops/quality/reports

命令行可以生成离线报告:

PYTHONPATH=src .venv/bin/python -m agent_os.feedback report \
  --from 2026-05-01 \
  --to 2026-05-03 \
  --capability sales.return_analysis \
  --out data/reports/quality/2026-05-01_2026-05-03

输出文件包括:

summary.md
traces.csv
feedback_cases.csv
needs_review.jsonl

这个入口的价值在于把“线上一次回答不符合预期”转成“某个日期范围内有多少 needs_review、哪些维度经常缺失、哪些 capability 问题最多、哪些 case 还没修”。Langfuse 仍然负责链路观测,例如 Agent OS Planner 调用、Data Query 插件内 Query Planner 调用、模型耗时、provider span 和异常栈;本地 trace / feedback 负责质量数据集和人工核对。一个回答“这次请求怎么跑的”,一个回答“这类业务问题是否已经沉淀、修复并回归”。

把这些节点串起来看,企业内部数据查询 Agent 的关键不在于让模型无约束地产生 SQL,而在于把自由度放在正确的位置。Data Query 插件内部的 Query Planner 负责把用户问题转换成 ReportSpec,CTE 和 YAML 负责定义受控数据面和业务语义,SQL builder 负责把查询规格编译成可审计的 SQL,权限和 guardrails 负责拦住越权和不可信查询,task_state 负责多轮合并,query trace 和 feedback case 负责持续改进。

这套方案也不会假装回答所有问题。退货率、退款率这种指标需要确认分母口径;跨域分析可能需要多个 capability 编排;写操作、审批、发券、改单需要更严格的授权和确认。真正重要的是边界清晰:用户看到的是业务结果,工程侧沉淀的是报表规格、执行 SQL、权限参数、质量 trace 和反馈 case。只有这样,企业数据查询 Agent 才能从一个自然语言查询 Demo,演进成可以长期迭代的生产能力。

继续阅读同主题文章数据与知识