基于 LangChain Agent + 通义千问实现自然语言数据分析与可视化平台
本章带你完成 阶段 3:「数析」智能数据分析台 项目: 基于 LangChain Agent + 通义千问 构建一个具备Agent 工具链设计、自然语言转数据分析、工具治理、人机协同审核的智能数据分析平台。
本章的难点不在“能跑一个 Pandas 脚本”,而在于把自然语言 → 可控的分析流程做成一个可交付的产品: 能工具化、可审计、可回放、可复盘。
AgentExecutor → 执行引擎create_react_agent() → 创建 ReAct 智能体@tool → 工具装饰器Pandas → 数据处理Matplotlib → 图表生成
# ============================
# 目的:用“工具化”的方式把 Pandas 能力做成可控产品,而不是让 LLM 直接写/执行任意脚本
#
# 你将看到:
# - 用 @tool 把“看表结构 / 统计指标 / 画图”等能力封装成受控工具
# - ReAct Agent 通过 Thought/Action/Observation 循环选择调用这些工具
# - verbose=True 时可以观察每一步工具调用轨迹
#
# 注意:本段是“最小可运行示例”,为了教学清晰做了简化:
# - 数据用内存 DataFrame 模拟
# - 绘图输出保存到本地文件
# ============================
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
from langchain.tools import tool
from langchain_classic.agents import AgentExecutor, create_react_agent
from langchain_community.chat_models import ChatTongyi
from langchain_core.prompts import ChatPromptTemplate
# 设置中文字体支持
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans'] # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号
# 示例数据:模拟 30 天、3 个渠道的 GMV 和订单数
# 后续工具都会基于这个 df 做分析
df = pd.DataFrame({
'date': pd.date_range('2024-01-01', periods=30),
'channel': ['A', 'B', 'C'] * 10,
'gmv': [1000, 1200, 800, 1500, 900, 1100] * 5,
'order_count': [50, 60, 40, 75, 45, 55] * 5
})
@tool
def inspect_schema(df_name: str) -> str:
"""检查 DataFrame 的列名和数据类型
Args:
df_name: 数据框名称(当前只支持示例数据)
"""
# 返回 schema 信息(列名 / 维度 / dtype),帮助模型做后续决策
return f"columns: {list(df.columns)}, shape: {df.shape}, dtypes: {df.dtypes.to_dict()}"
@tool
def compute_metrics(input_json: str) -> str:
"""按指定字段分组计算指标
Args:
input_json: JSON字符串,格式为 {"by": "字段名", "metric": "字段名", "top_n": 数字}
"""
import json
try:
params = json.loads(input_json) if isinstance(input_json, str) else input_json
by = params.get('by')
metric = params.get('metric')
top_n = params.get('top_n', 5)
except (json.JSONDecodeError, AttributeError) as e:
return f"参数解析错误: {e}"
# 基础校验:避免模型给空字段
if not by or not metric:
return "错误:必须提供 by 和 metric 参数"
# 字段校验:避免越权访问不存在字段
if by not in df.columns or metric not in df.columns:
return f"错误:字段 {by} 或 {metric} 不存在"
result = df.groupby(by)[metric].sum().sort_values(ascending=False)
return f"Top {top_n} {metric} by {by}:\n{result.head(top_n).to_string()}"
@tool
def compute_conversion_rate(input_json: str) -> str:
"""计算转化率
Args:
input_json: JSON字符串,格式为 {"by": "分组字段名"}
"""
import json
try:
params = json.loads(input_json) if isinstance(input_json, str) else input_json
by = params.get('by')
except (json.JSONDecodeError, AttributeError) as e:
return f"参数解析错误: {e}"
if not by:
return "错误:必须提供 by 参数"
if by not in df.columns:
return f"错误:字段 {by} 不存在"
# 计算转化率 = 订单数 / GMV (简化示例)
result = df.groupby(by).apply(lambda x: (x['order_count'].sum() / x['gmv'].sum()) * 100).round(2)
return f"各{by}转化率(%):\n{result.to_string()}"
@tool
def plot_trend(input_json: str) -> str:
"""绘制趋势图并保存
Args:
input_json: JSON字符串,格式为 {"metric": "字段名", "by": "分组字段名(可选)"}
"""
import json
try:
params = json.loads(input_json) if isinstance(input_json, str) else input_json
metric = params.get('metric')
by = params.get('by')
except (json.JSONDecodeError, AttributeError) as e:
return f"参数解析错误: {e}"
if not metric:
return "错误:必须提供 metric 参数"
if metric not in df.columns:
return f"错误:字段 {metric} 不存在"
try:
plt.figure(figsize=(10, 6))
if by:
if by not in df.columns:
return f"错误:分组字段 {by} 不存在"
for group in df[by].unique():
data = df[df[by] == group]
plt.plot(data['date'], data[metric], label=f'{by}={group}')
else:
plt.plot(df['date'], df[metric])
plt.title(f'{metric} 趋势图')
plt.xlabel('日期')
plt.ylabel(metric)
plt.legend()
plt.grid(True)
# 保存图片,忽略字体警告
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning) # 忽略字体警告
plt.savefig('trend_plot.png', bbox_inches='tight')
plt.close()
return "趋势图已保存为 trend_plot.png"
except Exception as e:
return f"绘图错误: {e}"
# 初始化 LLM 和 Agent
llm = ChatTongyi(model="qwen-turbo", temperature=0)
# ReAct 提示词模板
prompt = ChatPromptTemplate.from_template("""你是一个数据分析助手。使用以下工具来回答用户的问题。
可用工具:
{tools}
工具名称:
{tool_names}
使用以下格式:
Question: 用户的问题
Thought: 我需要思考如何回答这个问题
Action: 选择一个工具
Action Input: 工具的输入参数(必须是JSON格式)
Observation: 工具的输出结果
... (可以重复 Thought/Action/Action Input/Observation 多次)
Thought: 我现在知道最终答案了
Final Answer: 基于观察结果的最终答案(不要在Final Answer后再执行任何Action)
重要提示:
- inspect_schema 工具:查看数据结构,参数:{{"df_name": "数据框名称"}}
- compute_metrics 工具:计算分组指标,参数:{{"by": "字段名", "metric": "字段名", "top_n": 数字}}
- compute_conversion_rate 工具:计算转化率,参数:{{"by": "分组字段名"}}
- plot_trend 工具:绘制趋势图,参数:{{"metric": "字段名", "by": "分组字段名(可选)"}}
- 一旦给出Final Answer,就不能再执行任何Action
- 确保在Final Answer中完成所有需要的分析
开始!
Question: {input}
Thought: {agent_scratchpad}""")
# 创建 Agent
tools = [inspect_schema, compute_metrics, compute_conversion_rate, plot_trend]
agent = create_react_agent(llm, tools, prompt)
executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
max_iterations=5,
handle_parsing_errors=True, # 处理解析错误,让 Agent 重试
return_intermediate_steps=True # 返回中间轨迹(intermediate_steps)用于打印
)
# 执行示例
if __name__ == "__main__":
# 测试多个工具调用
questions = [
"哪个渠道的GMV最高?请画出各渠道GMV的趋势图",
"请分析各渠道的订单数量情况,并计算订单转化率",
"查看数据结构,然后给出整体数据分析报告"
]
for i, question in enumerate(questions, 1):
print(f"\n{'='*60}")
print(f"问题 {i}: {question}")
print('='*60)
result = executor.invoke({"input": question})
output = result.get("output", None) if isinstance(result, dict) else None
print(f"\n最终回答: {output}")
# 显示工具调用统计
steps = result.get("intermediate_steps", None) if isinstance(result, dict) else None
if steps:
print(f"\n工具调用轨迹:")
for step_num, (action, observation) in enumerate(steps, 1):
tool_name = getattr(action, "tool", None)
tool_input = getattr(action, "tool_input", None)
print(f" 步骤 {step_num}: 调用工具 '{tool_name}'")
print(f" 输入参数: {tool_input}")
obs_str = str(observation)
print(f" 输出结果: {obs_str[:100]}..." if len(obs_str) > 100 else f" 输出结果: {obs_str}")
print()
else:
print("\n工具调用轨迹: 未返回 intermediate_steps(请确认 return_intermediate_steps=True,或通过 verbose 日志查看轨迹)")
print(f"{'='*60}\n")// ============================
// 目的:给 LLM 一个“工具调用协议”的例子(结构化动作),让它输出可被系统解析的参数
//
// 含义:
// - tool:要调用的工具名(注册中心里必须存在)
// - args:该工具的入参(必须是白名单字段 + 合法范围)
//
// 注意:这里展示的是“结构化指令”,不是让模型写 Pandas 脚本
// ============================
{"tool":"groupby_sum","args":{"by":"channel","metric":"gmv","top_n":3}}production_agent_invoke() → 入口函数,统一处理rate_limiter.is_allowed() → 限流保护is_high_risk_operation() → 高危检测create_agent() → 创建智能体@tool safe_groupby() → 工具执行logger.info() → 监控日志
# ============================
# 目的:生产级“数据分析 Agent”骨架(可上线思路示例)
#
# 这一段强调“产品化”必备能力:
# - 用户级限流(防滥用)
# - 高危操作检测 + Human-in-the-Loop
# - 审计日志(trace_id、risk_level、耗时、状态)
# - 工具白名单 + 参数校验 + top_n/导出行数上限
# - 可观测性:日志、耗时、成功率
#
# 注意:这是教学示例,重点是架构与治理点,部分细节需按你实际框架版本做适配。
# ============================
import os
import json
import time
import logging
import uuid
import hashlib
from typing import Dict, Any, List, Optional
from datetime import datetime
from dataclasses import dataclass
from enum import Enum
from langchain.tools import tool
from langchain.agents import create_agent
from langchain.agents.middleware.model_call_limit import ModelCallLimitMiddleware
from langchain.agents.middleware.tool_call_limit import ToolCallLimitMiddleware
from langchain.agents.middleware.model_retry import ModelRetryMiddleware
from langchain.agents.middleware.tool_retry import ToolRetryMiddleware
from langchain_community.chat_models import ChatTongyi
from langchain_core.runnables import RunnableConfig
from langchain_core.messages import HumanMessage, AIMessage
# === 数据处理库 ===
import pandas as pd
import numpy as np
# === 生产环境推荐组合 ===
# 1️⃣ 安全:system_prompt + middleware(限流)+ interrupt_before(高危审批)
# 2️⃣ 可靠:middleware(重试)+ name(监控标识)
# 3️⃣ 可观测:name + debug=False + 外部日志系统 + 审计追踪
# 配置日志系统(生产建议:输出到集中式日志平台;这里用文件+控制台演示)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('/tmp/data_analysis_agent.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger("data_analysis_agent")
# 审计日志记录器
audit_logger = logging.getLogger("audit")
# === 数据模型 ===
@dataclass
class AuditLog:
"""审计日志数据模型"""
trace_id: str
user_id: str
operation: str
status: str
timestamp: datetime
tools_used: List[str]
risk_level: str
execution_time: float
error_message: Optional[str] = None
class RiskLevel(Enum):
"""风险级别枚举"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class OperationStatus(Enum):
"""操作状态枚举"""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
COMPLETED = "completed"
FAILED = "failed"
# === 高级限流器(只保留用户维度) ===
# 说明:限流的第一层防线,按 user_id 统计调用频次,保护系统不被刷爆
class AdvancedRateLimiter:
"""高级限流器:当前示例中只做“用户级别”限流,便于和业务账号体系对齐"""
def __init__(self):
# 仅按 user_id 做滑动时间窗口统计
self.user_limits: dict[str, list[float]] = {}
def is_allowed(self, user_id: str) -> tuple[bool, str]:
"""检查是否允许请求,返回 (是否允许, 限制原因)
这里的策略是:同一 user_id 在 60 秒窗口内最多 10 次调用。
"""
now = time.time()
key = f"user_{user_id}"
window_seconds = 60
max_requests = 10
if key not in self.user_limits:
self.user_limits[key] = []
# 清理窗口外的旧请求
self.user_limits[key] = [
ts for ts in self.user_limits[key]
if now - ts < window_seconds
]
if len(self.user_limits[key]) >= max_requests:
return False, f"用户限流:{user_id} 在 {window_seconds} 秒内最多 {max_requests} 次调用"
# 记录本次调用时间戳
self.user_limits[key].append(now)
return True, "允许"
rate_limiter = AdvancedRateLimiter()
# === 高级安全检测器 ===
class SecurityAnalyzer:
"""高级安全分析器"""
def __init__(self):
self.high_risk_keywords = [
"删除", "drop", "truncate", "清空", "覆盖", "全量", "所有数据",
"export", "导出", "下载", "敏感", "权限", "管理员", "root"
]
self.sensitive_fields = {"salary", "phone", "email", "id_card", "bank_account"}
self.destructive_tools = {"delete_data", "truncate_table", "drop_table"}
def analyze_risk(self, input_text: str, tools_used: List[str], fields_accessed: List[str]) -> RiskLevel:
"""分析操作风险级别"""
risk_score = 0
# 关键词检测
if any(keyword in input_text.lower() for keyword in self.high_risk_keywords):
risk_score += 3
# 敏感字段检测
if any(field in self.sensitive_fields for field in fields_accessed):
risk_score += 2
# 破坏性工具检测
if any(tool in self.destructive_tools for tool in tools_used):
risk_score += 3
# 大量数据导出检测
if "export" in input_text.lower() or "导出" in input_text:
risk_score += 2
# 数据量检测
if any(word in input_text.lower() for word in ["10000", "一万", "all", "全部"]):
risk_score += 1
if risk_score >= 5:
return RiskLevel.HIGH
elif risk_score >= 3:
return RiskLevel.MEDIUM
else:
return RiskLevel.LOW
def is_high_risk_operation(self, input_text: str) -> bool:
"""检测是否为高危操作"""
risk = self.analyze_risk(input_text, [], [])
return risk == RiskLevel.HIGH
security_analyzer = SecurityAnalyzer()
# === 审计追踪系统 ===
class AuditTrail:
"""审计追踪系统"""
def __init__(self):
self.logs = []
def create_log(self, user_id: str, operation: str, risk_level: RiskLevel) -> AuditLog:
"""创建审计日志"""
trace_id = str(uuid.uuid4())[:8]
return AuditLog(
trace_id=trace_id,
user_id=user_id,
operation=operation,
status=OperationStatus.PENDING.value,
timestamp=datetime.now(),
tools_used=[],
risk_level=risk_level.value,
execution_time=0.0
)
def log_operation(self, audit_log: AuditLog):
"""记录操作日志"""
log_data = {
"trace_id": audit_log.trace_id,
"user_id": audit_log.user_id,
"operation": audit_log.operation,
"status": audit_log.status,
"timestamp": audit_log.timestamp.isoformat(),
"tools_used": audit_log.tools_used,
"risk_level": audit_log.risk_level,
"execution_time": audit_log.execution_time,
"error_message": audit_log.error_message
}
# 记录到审计日志
audit_logger.info(json.dumps(log_data, ensure_ascii=False))
# 记录到内存(实际生产环境应该用数据库)
self.logs.append(log_data)
audit_trail = AuditTrail()
# === 性能监控器 ===
class PerformanceMonitor:
"""性能监控器"""
def __init__(self):
self.metrics = {}
def record_execution(self, tool_name: str, execution_time: float, success: bool):
"""记录执行指标"""
if tool_name not in self.metrics:
self.metrics[tool_name] = {
"total_calls": 0,
"success_calls": 0,
"total_time": 0.0,
"avg_time": 0.0,
"success_rate": 0.0
}
metrics = self.metrics[tool_name]
metrics["total_calls"] += 1
metrics["total_time"] += execution_time
metrics["avg_time"] = metrics["total_time"] / metrics["total_calls"]
if success:
metrics["success_calls"] += 1
metrics["success_rate"] = metrics["success_calls"] / metrics["total_calls"]
# 记录到日志
logger.info(f"Performance: {tool_name} - avg_time: {metrics['avg_time']:.3f}s, success_rate: {metrics['success_rate']:.2%}")
performance_monitor = PerformanceMonitor()
# === 示例数据 ===
import pandas as pd
import numpy as np
# 生成更丰富的示例数据
np.random.seed(42)
dates = pd.date_range('2024-01-01', periods=100)
channels = ['A', 'B', 'C', 'D', 'E']
regions = ['北京', '上海', '广州', '深圳', '杭州']
df = pd.DataFrame({
'date': np.random.choice(dates, 1000),
'channel': np.random.choice(channels, 1000),
'region': np.random.choice(regions, 1000),
'gmv': np.random.normal(1000, 300, 1000).astype(int),
'order_count': np.random.poisson(50, 1000),
'user_count': np.random.poisson(100, 1000),
'product_category': np.random.choice(['电子产品', '服装', '食品', '家居'], 1000)
})
# 确保数据合理性
df['gmv'] = df['gmv'].clip(lower=100)
df['order_count'] = df['order_count'].clip(lower=1)
df['user_count'] = df['user_count'].clip(lower=1)
# === 数据分析工具(生产级封装) ===
# 说明:工具化的核心是“白名单 + 上限”,确保模型只能在安全边界内做分析
ALLOWED_COLUMNS = {"channel", "gmv", "order_count", "date", "region", "user_count", "product_category"}
ALLOWED_AGGREGATIONS = {"sum", "mean", "count", "max", "min", "std"}
MAX_TOP_N = 100
MAX_EXPORT_ROWS = 10000
@tool
def safe_groupby(input_json: str) -> str:
"""安全的分组聚合(生产级实现)
Args:
input_json: JSON格式,{"by": ["字段名"], "metric": "字段名", "agg": "聚合方式", "top_n": 数字}
"""
# 记录耗时,用于性能追踪
start_time = time.time()
tool_name = "safe_groupby"
try:
# 解析参数(来自 LLM 的结构化 JSON)
params = json.loads(input_json)
group_by = params.get("by", [])
metric = params.get("metric")
agg_func = params.get("agg", "sum")
top_n = min(params.get("top_n", 5), MAX_TOP_N)
# 参数验证(字段白名单 + 聚合方式白名单 + top_n 上限)
if not isinstance(group_by, list):
group_by = [group_by]
if not all(col in ALLOWED_COLUMNS for col in group_by + [metric]):
return f"错误:只允许字段 {ALLOWED_COLUMNS}"
if agg_func not in ALLOWED_AGGREGATIONS:
return f"错误:只允许聚合方式 {ALLOWED_AGGREGATIONS}"
# 执行聚合(只允许预定义操作,避免复杂/危险表达式)
if agg_func == "sum":
result = df.groupby(group_by)[metric].sum().nlargest(top_n)
elif agg_func == "mean":
result = df.groupby(group_by)[metric].mean().nlargest(top_n)
elif agg_func == "count":
result = df.groupby(group_by)[metric].count().nlargest(top_n)
elif agg_func == "max":
result = df.groupby(group_by)[metric].max().nlargest(top_n)
elif agg_func == "min":
result = df.groupby(group_by)[metric].min().nlargest(top_n)
elif agg_func == "std":
result = df.groupby(group_by)[metric].std().nlargest(top_n)
result_str = result.to_string()
# 记录性能指标
execution_time = time.time() - start_time
performance_monitor.record_execution(tool_name, execution_time, True)
# 监控记录
logger.info(json.dumps({
"timestamp": datetime.now().isoformat(),
"tool_name": tool_name,
"execution_time": round(execution_time, 3),
"status": "success",
"params": input_json[:100] + "..." if len(input_json) > 100 else input_json,
"result_rows": len(result)
}))
return result_str
except Exception as e:
execution_time = time.time() - start_time
performance_monitor.record_execution(tool_name, execution_time, False)
logger.error(json.dumps({
"timestamp": datetime.now().isoformat(),
"tool_name": tool_name,
"execution_time": round(execution_time, 3),
"status": "error",
"error": str(e),
"params": input_json[:100] + "..." if len(input_json) > 100 else input_json
}))
return f"执行错误:{str(e)}"
@tool
def safe_filter(input_json: str) -> str:
"""安全的数据过滤
Args:
input_json: JSON格式,{"conditions": [{"field": "字段名", "operator": "操作符", "value": "值"}], "limit": 数字}
"""
start_time = time.time()
tool_name = "safe_filter"
try:
params = json.loads(input_json)
conditions = params.get("conditions", [])
limit = min(params.get("limit", 100), MAX_EXPORT_ROWS)
# 参数验证
for condition in conditions:
field = condition.get("field")
if field not in ALLOWED_COLUMNS:
return f"错误:不允许字段 {field}"
# 构建过滤条件
filtered_df = df.copy()
for condition in conditions:
field = condition["field"]
operator = condition["operator"]
value = condition["value"]
if operator == "eq":
filtered_df = filtered_df[filtered_df[field] == value]
elif operator == "gt":
filtered_df = filtered_df[filtered_df[field] > value]
elif operator == "lt":
filtered_df = filtered_df[filtered_df[field] < value]
elif operator == "in":
if isinstance(value, list):
filtered_df = filtered_df[filtered_df[field].isin(value)]
else:
filtered_df = filtered_df[filtered_df[field] == value]
result = filtered_df.head(limit).to_string()
# 记录性能指标
execution_time = time.time() - start_time
performance_monitor.record_execution(tool_name, execution_time, True)
return result
except Exception as e:
execution_time = time.time() - start_time
performance_monitor.record_execution(tool_name, execution_time, False)
return f"过滤错误:{str(e)}"
@tool
def detect_anomalies(input_json: str) -> str:
"""异常检测
Args:
input_json: JSON格式,{"metric": "指标名", "method": "检测方法", "threshold": 阈值}
"""
start_time = time.time()
tool_name = "detect_anomalies"
try:
params = json.loads(input_json)
metric = params.get("metric")
method = params.get("method", "zscore")
threshold = params.get("threshold", 2.0)
if metric not in ALLOWED_COLUMNS:
return f"错误:不允许字段 {metric}"
# 简单的异常检测
if method == "zscore":
z_scores = np.abs((df[metric] - df[metric].mean()) / df[metric].std())
anomalies = df[z_scores > threshold]
result = f"检测到 {len(anomalies)} 个异常点\n"
result += f"异常值范围:{anomalies[metric].min():.2f} - {anomalies[metric].max():.2f}\n"
result += "异常样本:\n" + anomalies.head(5).to_string()
# 记录性能指标
execution_time = time.time() - start_time
performance_monitor.record_execution(tool_name, execution_time, True)
return result
except Exception as e:
execution_time = time.time() - start_time
performance_monitor.record_execution(tool_name, execution_time, False)
return f"异常检测错误:{str(e)}"
# === 创建生产级 Agent ===
# 说明:create_agent 负责把 model + tools + system_prompt 组装为一个可调用的 Agent
api_key = os.getenv("DASHSCOPE_API_KEY")
llm = ChatTongyi(model_name="qwen-turbo", dashscope_api_key=api_key, temperature=0)
# 系统提示词(安全边界):把“不能做什么/只能做什么”写清楚,比事后兜底更重要
SYSTEM_PROMPT = """你是一个智能数据分析助手。请遵循以下规则:
🔒 安全约束:
1. 只能使用提供的工具,不能执行任意代码
2. 敏感操作(删除、导出全量数据等)需要人工审批
3. 严格遵守数据隐私和权限边界
📊 分析规范:
1. 先理解业务问题,再选择合适的分析工具
2. 确保分析结果可解释、可复现
3. 重要结论要提供数据支撑
⚡ 性能要求:
1. 优先使用预聚合数据,避免全表扫描
2. 合理设置 top_n,防止返回过大数据集
3. 复杂分析要分步骤执行
🛠️ 可用工具:
- safe_groupby: 分组聚合分析
- safe_filter: 数据过滤查询
- detect_anomalies: 异常检测分析"""
# 创建 Agent(带监控标识)
# name:用于监控标识(例如打到 tracing/metrics 系统)
data_analysis_agent = create_agent(
model=llm,
tools=[safe_groupby, safe_filter, detect_anomalies],
system_prompt=SYSTEM_PROMPT,
name="data_analysis_agent", # 监控标识
debug=False, # 生产环境关闭 debug
# 中间件:示例展示按“单次运行”维度控制调用次数
# 注意:ModelCallLimitMiddleware / ToolCallLimitMiddleware 需根据你实际使用的 LangChain 版本
# 引入对应实现或自定义实现,这里只作为 API 用法示意。
middleware=(
# 限流保护:防止单次运行内滥用模型 / 工具
ModelCallLimitMiddleware(run_limit=10), # 每次运行最多 10 次模型调用
ToolCallLimitMiddleware(run_limit=5), # 每次运行最多 5 次工具调用
),
)
# === 高级生产级调用包装器 ===
class ProductionAgentWrapper:
"""生产级 Agent 包装器:完整的安全、监控、审计功能"""
def __init__(self, agent, user_id: str = "default_user"):
self.agent = agent
self.user_id = user_id
def invoke(self, user_input: str, require_approval: bool = True) -> Dict[str, Any]:
"""生产级 Agent 调用"""
trace_id = str(uuid.uuid4())[:8]
start_time = time.time()
# 创建审计日志
risk_level = security_analyzer.analyze_risk(user_input, [], [])
audit_log = audit_trail.create_log(self.user_id, user_input, risk_level)
try:
# 1. 用户级限流检查(第一道保护)
allowed, reason = rate_limiter.is_allowed(self.user_id)
if not allowed:
audit_log.status = OperationStatus.REJECTED.value
audit_log.error_message = reason
audit_trail.log_operation(audit_log)
return {
"status": "rate_limited",
"message": reason,
"trace_id": trace_id,
"agent_name": "data_analysis_agent"
}
# 2. 高危操作检测(需要人工确认时先返回 pending)
if require_approval and risk_level == RiskLevel.HIGH:
audit_log.status = OperationStatus.PENDING.value
audit_trail.log_operation(audit_log)
return {
"status": "pending_approval",
"message": "检测到高危操作,需要人工审批",
"requires_approval": True,
"operation": user_input,
"trace_id": trace_id,
"risk_level": risk_level.value,
"agent_name": "data_analysis_agent"
}
# 3. 执行分析:将用户输入封装为 messages 交给 Agent
config = RunnableConfig(
callbacks=[],
tags=["production", "data_analysis"],
metadata={"trace_id": trace_id, "user_id": self.user_id}
)
result = self.agent.invoke(
{"messages": [HumanMessage(content=user_input)]},
config=config
)
# 4. 记录成功日志
execution_time = time.time() - start_time
audit_log.status = OperationStatus.COMPLETED.value
audit_log.execution_time = execution_time
audit_log.tools_used = ["data_analysis_agent"]
audit_trail.log_operation(audit_log)
# 5. 记录性能指标
logger.info(json.dumps({
"timestamp": datetime.now().isoformat(),
"trace_id": trace_id,
"user_id": self.user_id,
"agent_name": "data_analysis_agent",
"execution_time": round(execution_time, 3),
"status": "success",
"query": user_input[:100] + "..." if len(user_input) > 100 else user_input,
"risk_level": risk_level.value
}))
return {
"status": "success",
"result": result["messages"][-1].content,
"trace_id": trace_id,
"agent_name": "data_analysis_agent",
"execution_time": round(execution_time, 3),
"risk_level": risk_level.value
}
except Exception as e:
execution_time = time.time() - start_time
# 记录错误日志
audit_log.status = OperationStatus.FAILED.value
audit_log.execution_time = execution_time
audit_log.error_message = str(e)
audit_trail.log_operation(audit_log)
logger.error(json.dumps({
"timestamp": datetime.now().isoformat(),
"trace_id": trace_id,
"user_id": self.user_id,
"agent_name": "data_analysis_agent",
"execution_time": round(execution_time, 3),
"status": "error",
"error": str(e),
"query": user_input
}))
return {
"status": "error",
"error": str(e),
"trace_id": trace_id,
"agent_name": "data_analysis_agent"
}
def get_performance_metrics(self) -> Dict[str, Any]:
"""获取性能指标"""
return {
"performance_metrics": performance_monitor.metrics,
"recent_logs": audit_trail.logs[-10:], # 最近10条日志
"system_status": "healthy"
}
# 创建生产级 Agent 实例
production_agent = ProductionAgentWrapper(data_analysis_agent, "user_001")
# === 使用示例 ===
if __name__ == "__main__":
print("=== 生产级数据分析系统演示 ===\n")
# 示例1:正常分析请求
print("1. 正常分析请求:")
response1 = production_agent.invoke(
'{"by": ["channel"], "metric": "gmv", "agg": "sum", "top_n": 3}'
)
print(f"状态: {response1['status']}")
print(f"结果: {response1.get('result', '无结果')[:100]}...")
print(f"执行时间: {response1.get('execution_time', 0)}秒")
print(f"风险级别: {response1.get('risk_level', 'unknown')}")
print()
# 示例2:数据过滤请求
print("2. 数据过滤请求:")
response2 = production_agent.invoke(
'{"conditions": [{"field": "channel", "operator": "in", "value": ["A", "B"]}], "limit": 5}'
)
print(f"状态: {response2['status']}")
print(f"结果预览: {response2.get('result', '无结果')[:100]}...")
print()
# 示例3:异常检测请求
print("3. 异常检测请求:")
response3 = production_agent.invoke(
'{"metric": "gmv", "method": "zscore", "threshold": 2.0}'
)
print(f"状态: {response3['status']}")
print(f"结果预览: {response3.get('result', '无结果')[:100]}...")
print()
# 示例4:高危操作请求
print("4. 高危操作请求:")
response4 = production_agent.invoke("删除所有渠道数据")
print(f"状态: {response4['status']}")
print(f"消息: {response4.get('message', '无消息')}")
print(f"需要审批: {response4.get('requires_approval', False)}")
print()
# 示例5:获取性能指标
print("5. 性能指标:")
metrics = production_agent.get_performance_metrics()
print(f"工具调用统计: {len(metrics['performance_metrics'])} 个工具")
for tool_name, tool_metrics in metrics['performance_metrics'].items():
print(f" {tool_name}: {tool_metrics['total_calls']}次调用, "
f"成功率 {tool_metrics['success_rate']:.1%}, "
f"平均耗时 {tool_metrics['avg_time']:.3f}秒")
print()
print("=== 演示完成 ===")# ============================
# 目的:展示“先计划再执行”的最小闭环(Plan-and-Execute 思路)
#
# 你将看到:
# - 第一步让模型把自然语言问题“翻译”为结构化计划(JSON)
# - 第二步由系统按计划逐步调用工具(或函数)执行
# - 计划受 schema 约束,保证口径一致、可复现、可审计
#
# 注意:这段代码用通义千问(ChatTongyi)演示“真实 API 调用”,并保持与本章其它模块一致。
# 安全建议:
# - API Key 必须从环境变量读取,禁止写死在代码里
# - 计划必须做白名单约束(允许工具、允许字段、最大步数)
# ============================
import json
import time
import uuid
import os
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import SystemMessage, HumanMessage
# 初始化大模型(与本章其它代码保持一致)
api_key = os.getenv("DASHSCOPE_API_KEY")
if not api_key:
raise ValueError("请先在环境变量中设置 DASHSCOPE_API_KEY")
llm = ChatTongyi(model_name="qwen-turbo", dashscope_api_key=api_key, temperature=0)
# 模拟业务数据
sample_data = {
"sales_data": [
{"date": "2024-01-01", "channel": "A", "gmv": 1200, "orders": 60},
{"date": "2024-01-02", "channel": "B", "gmv": 800, "orders": 40},
{"date": "2024-01-03", "channel": "C", "gmv": 1500, "orders": 75},
# ... 更多数据
]
}
# 计划 Schema:约束模型输出,避免“随口编计划”
PLAN_SCHEMA = {
"required": ["intent", "range", "steps", "deliver"],
"step_required": ["tool", "args"],
"allowed_tools": {"safe_filter", "safe_groupby", "detect_anomalies", "plot"},
"allowed_ranges": {"last_7d", "last_30d", "this_month", "custom"},
"max_steps": 6,
}
# 增强的计划生成提示词:让模型理解业务上下文,并输出“可执行计划”
PLAN_PROMPT = """你是专业的数据分析规划师。请将用户问题转换为结构化的分析计划JSON。
业务背景:
- 数据源:电商销售数据,包含字段:date(日期), channel(渠道), gmv(销售额), orders(订单数)
- 支持的分析:趋势分析、渠道对比、异常检测、转化率计算
- 输出要求:可执行的工具链步骤
请严格按照以下JSON格式输出,不要包含任何其他文字:
{
"intent": "分析意图描述",
"range": "时间范围(last_7d/last_30d/this_month/custom)",
"steps": [
{"tool": "工具名", "args": {"参数名": "参数值"}},
...
],
"deliver": "交付物描述"
}
可用工具:
- safe_filter: 数据过滤 {"conditions": [{"field": "字段", "op": ">", "value": "数值"}], "limit": 数量}
- safe_groupby: 分组统计 {"by": ["字段"], "metric": "指标", "agg": "sum/avg/count", "top_n": 数量}
- detect_anomalies: 异常检测 {"metric": "指标", "method": "std/iqr"}
- plot: 图表生成 {"chart": "line/bar/pie", "x": "X轴字段", "y": "Y轴字段", "series": "分组字段"}
示例:
用户问题:"近30天各渠道GMV趋势分析"
输出:
{
"intent": "分析近30天各渠道GMV趋势变化",
"range": "last_30d",
"steps": [
{"tool": "safe_filter", "args": {"conditions": [{"field": "date", "op": ">=", "value": "2024-01-01"}], "limit": 1000}},
{"tool": "safe_groupby", "args": {"by": ["channel", "date"], "metric": "gmv", "agg": "sum", "top_n": 50}},
{"tool": "plot", "args": {"chart": "line", "x": "date", "y": "gmv", "series": "channel"}}
],
"deliver": "渠道GMV趋势图+数据洞察"
}
现在请处理用户问题:"""
@dataclass
class TraceEvent:
name: str
ok: bool
ms: int
meta: Dict[str, Any]
class Trace:
def __init__(self, query: str):
self.trace_id = f"t-{uuid.uuid4().hex[:6]}"
self.query = query
self.start = time.time()
self.events: List[TraceEvent] = []
def log(self, name: str, ok: bool, meta: Optional[Dict[str, Any]] = None):
meta = meta or {}
self.events.append(
TraceEvent(
name=name,
ok=ok,
ms=round((time.time() - self.start) * 1000),
meta=meta,
)
)
def summary(self) -> Dict[str, Any]:
return {
"trace_id": self.trace_id,
"query": self.query,
"events": [e.__dict__ for e in self.events],
}
def call_llm_for_planning(query: str) -> Dict[str, Any]:
"""使用真实大模型生成分析计划"""
try:
resp = llm.invoke(
[
SystemMessage(content="你是专业的数据分析规划师,只输出JSON格式的分析计划。"),
HumanMessage(content=PLAN_PROMPT + query),
]
)
content = (resp.content or "").strip()
# 清理可能的markdown标记
if content.startswith("```json"):
content = content[7:]
if content.endswith("```"):
content = content[:-3]
return json.loads(content)
except Exception as e:
print(f"大模型调用失败: {e}")
# 降级到示例计划
return {
"intent": "分析渠道GMV趋势",
"range": "last_30d",
"steps": [
{"tool": "safe_filter", "args": {"conditions": [], "limit": 1000}},
{"tool": "safe_groupby", "args": {"by": ["channel"], "metric": "gmv", "agg": "sum", "top_n": 5}},
{"tool": "plot", "args": {"chart": "line", "x": "date", "y": "gmv", "series": "channel"}}
],
"deliver": "图表+结论"
}
def parse_query_to_plan(query: str, use_llm: bool = True) -> Dict[str, Any]:
"""自然语言 → 结构化分析计划"""
if use_llm:
return call_llm_for_planning(query)
else:
# 直接执行模式(无计划)
return {"direct_execution": True, "query": query}
def validate_plan(plan: Dict[str, Any]) -> Tuple[bool, str]:
"""计划校验:必填字段、工具白名单、步数上限、字段类型"""
if "direct_execution" in plan:
return True, "直接执行模式"
missing = [k for k in PLAN_SCHEMA["required"] if k not in plan]
if missing:
return False, f"计划缺少字段: {missing}"
if plan.get("range") not in PLAN_SCHEMA["allowed_ranges"]:
return False, f"range 不合法: {plan.get('range')}"
steps = plan.get("steps")
if not isinstance(steps, list) or len(steps) == 0:
return False, "steps 必须为非空数组"
if len(steps) > PLAN_SCHEMA["max_steps"]:
return False, f"steps 过多: {len(steps)} > {PLAN_SCHEMA['max_steps']}"
for i, s in enumerate(steps):
if not isinstance(s, dict):
return False, f"step[{i}] 必须为对象"
for k in PLAN_SCHEMA["step_required"]:
if k not in s:
return False, f"step[{i}] 缺少字段: {k}"
if s["tool"] not in PLAN_SCHEMA["allowed_tools"]:
return False, f"step[{i}] tool 不允许: {s['tool']}"
if not isinstance(s["args"], dict):
return False, f"step[{i}] args 必须为对象"
return True, "ok"
def assess_risk(plan: Dict[str, Any]) -> str:
"""风险评估"""
if "direct_execution" in plan:
return "high" # 直接执行风险较高
HIGH_RISK = {"export", "delete", "drop", "truncate", "salary", "id_card"}
text = json.dumps(plan, ensure_ascii=False).lower()
return "high" if any(k in text for k in HIGH_RISK) else "low"
def compile_plan(plan: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]:
"""计划 → 可执行步骤"""
if "direct_execution" in plan:
return [("direct_execute", {"query": plan["query"]})]
return [(s["tool"], s["args"]) for s in plan["steps"]]
def execute_plan(steps: List[Tuple[str, Dict[str, Any]]], tools: Dict[str, Any], trace: Trace) -> Dict[str, Any]:
"""按步骤执行:每步记录 trace"""
ctx: Dict[str, Any] = {}
for tool_name, args in steps:
t0 = time.time()
try:
fn = tools[tool_name]
out = fn(args, ctx)
ctx[tool_name] = out
trace.log(tool_name, True, {"ms_step": round((time.time() - t0) * 1000), "result_count": len(str(out))})
except Exception as e:
trace.log(tool_name, False, {"error": str(e)})
raise
return ctx
# 工具实现
def safe_filter_tool(args: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, Any]:
"""安全的数据过滤工具"""
print(f"🔍 执行数据过滤: {args}")
time.sleep(0.1) # 模拟处理时间
return {"filtered_rows": 856, "data_preview": "date,channel,gmv,orders"}
def safe_groupby_tool(args: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, Any]:
"""安全的分组统计工具"""
print(f"📊 执行分组统计: {args}")
time.sleep(0.2) # 模拟计算时间
return {
"groupby_result": [
{"channel": "A", "gmv": 15600, "orders": 780},
{"channel": "B", "gmv": 12300, "orders": 615},
{"channel": "C", "gmv": 18900, "orders": 945}
]
}
def detect_anomalies_tool(args: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, Any]:
"""异常检测工具"""
print(f"🚨 执行异常检测: {args}")
time.sleep(0.15) # 模拟检测时间
return {"anomalies": 2, "anomaly_points": ["2024-01-15", "2024-01-23"]}
def plot_tool(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
"""图表生成工具"""
print(f"📈 生成图表: {args}")
time.sleep(0.3) # 模拟绘图时间
return "chart_saved_to=/output/channel_gmv_trend.png"
def call_llm_direct_answer(query: str) -> str:
"""直接执行模式:一次大模型调用直出答案(更贴近真实世界的“无计划”形态)"""
resp = llm.invoke(
[
SystemMessage(content=(
"你是数据分析助手。现在是‘直接回答’模式:"
"不要输出JSON计划,不要输出工具步骤,只输出最终结论与关键依据。"
"如果缺少数据,请明确说明假设与不确定性。"
)),
HumanMessage(content=query),
]
)
return (resp.content or "").strip()
def direct_execute_tool(args: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, Any]:
"""直接执行工具(无计划模式)"""
query = args["query"]
print(f"⚡ 直接执行(一次 LLM 调用直出): {query}")
answer = call_llm_direct_answer(query)
return {"answer": answer}
def demonstrate_planning_vs_direct():
"""演示:先计划再执行 vs 直接执行的效果对比"""
test_queries = [
"近30天各渠道GMV趋势分析,找出异常点",
"对比不同渠道的订单转化率,给出优化建议",
"分析上周销售数据,识别表现异常的产品"
]
tools = {
"safe_filter": safe_filter_tool,
"safe_groupby": safe_groupby_tool,
"detect_anomalies": detect_anomalies_tool,
"plot": plot_tool,
"direct_execute": direct_execute_tool,
}
print("=" * 80)
print("🎯 先结构化计划,再执行 - vs - 直接执行模式对比")
print("=" * 80)
for i, query in enumerate(test_queries, 1):
print(f"\n📋 测试问题 {i}: {query}")
print("-" * 60)
# 模式1:先计划再执行
print("\n🔧 模式1: 先结构化计划,再执行")
trace1 = Trace(query)
try:
plan = parse_query_to_plan(query, use_llm=True)
print(f"📝 生成的计划:")
print(json.dumps(plan, ensure_ascii=False, indent=2))
ok, reason = validate_plan(plan)
trace1.log("validate_plan", ok, {"reason": reason})
print(f"✅ 计划验证: {'通过' if ok else '失败'} - {reason}")
if ok:
risk = assess_risk(plan)
trace1.log("assess_risk", True, {"risk": risk})
print(f"⚠️ 风险评估: {risk}")
steps = compile_plan(plan)
print(f"🎯 执行步骤: {len(steps)} 步")
ctx = execute_plan(steps, tools, trace1)
print(f"🎉 执行完成,结果: {len(ctx)} 个工具输出")
else:
print("❌ 计划验证失败,跳过执行")
except Exception as e:
print(f"❌ 计划模式执行失败: {e}")
print(f"\n📊 计划模式执行轨迹:")
for event in trace1.events:
status = "✅" if event.ok else "❌"
print(f" {status} {event.name} ({event.ms}ms) - {event.meta}")
# 模式2:直接执行
print("\n⚡ 模式2: 直接执行(无计划)")
trace2 = Trace(query)
try:
plan2 = parse_query_to_plan(query, use_llm=False)
steps2 = compile_plan(plan2)
ctx2 = execute_plan(steps2, tools, trace2)
print(f"🎉 直接执行完成")
if isinstance(ctx2, dict) and "direct_execute" in ctx2 and isinstance(ctx2["direct_execute"], dict):
ans = ctx2["direct_execute"].get("answer", "")
if ans:
print("\n🧾 直接模式答案预览:")
print(ans[:400] + ("..." if len(ans) > 400 else ""))
except Exception as e:
print(f"❌ 直接执行失败: {e}")
print(f"\n📊 直接执行轨迹:")
for event in trace2.events:
status = "✅" if event.ok else "❌"
print(f" {status} {event.name} ({event.ms}ms) - {event.meta}")
# 对比总结
print(f"\n🔄 效果对比:")
plan_steps = len([e for e in trace1.events if e.name in ["safe_filter", "safe_groupby", "detect_anomalies", "plot"]])
direct_steps = len([e for e in trace2.events if e.name == "direct_execute"])
plan_time = sum(e.ms for e in trace1.events)
direct_time = sum(e.ms for e in trace2.events)
print(f" 📈 计划模式: {plan_steps} 个工具调用, 耗时 {plan_time}ms")
print(f" ⚡ 直接模式: {direct_steps} 个工具调用, 耗时 {direct_time}ms")
print(f" 🎯 计划模式优势: 可追溯、可复现、可控制、可审计")
print(f" ⚡ 直接模式优势: 响应快、简单直接")
print(f" 📝 建议: 生产环境推荐计划模式,开发调试可用直接模式")
if __name__ == "__main__":
# 运行演示
demonstrate_planning_vs_direct()
print("\n" + "=" * 80)
print("🎯 核心结论:先结构化计划,再执行的价值")
print("=" * 80)
print("✅ 可复现性: 同一问题多次执行,步骤和结果一致")
print("✅ 可控制性: 每个步骤都有明确的输入输出,便于调试")
print("✅ 可审计性: 完整的执行轨迹,满足合规要求")
print("✅ 可扩展性: 新增工具只需扩展计划模板")
print("✅ 可解释性: 用户能理解AI的\"思考过程\"")
print("⚠️ 权衡: 增加了执行时间和复杂度")
print("🚀 生产建议: 关键业务必须使用计划模式,简单查询可考虑直接模式")// ============================
// 目的:审计日志(audit log)示例 —— “拒绝也要留痕”
//
// 字段解释:
// - trace_id:一次请求/一次任务的全链路追踪 ID
// - user:触发操作的用户(或角色)
// - tool:尝试调用的工具
// - args:工具入参(必要时需要脱敏/截断)
// - status:allow / deny / pending 等
// - reason:拒绝原因(权限不足、超限、风险过高...)
// ============================
{"trace_id":"t-xxx","user":"ops","tool":"export_report","args":{"format":"pdf"},"status":"deny","reason":"permission"}# ============================
# 目的:最小“权限 / 审计 / 限流”可运行示例(复制即可跑)
#
# 你将得到:
# - 工具注册(工具名/权限级别/实现函数)
# - 权限校验:deny 也必须写审计
# - 用户维度限流:同一用户 1 秒内最多 N 次
# - 统一的受保护调用入口:guarded_call(...)
# ============================
import json
import time
import uuid
from collections import deque
from typing import Any, Callable, Dict, Deque
# 角色等级映射:把“角色字符串”转成可比较的数字。
# 生产环境通常来自:账号系统 / IAM / OAuth claims。
ROLE_LEVELS = {"viewer": 1, "editor": 2, "admin": 3}
def audit_log(
*,
trace_id: str,
user: str,
role: str,
tool: str,
args: Dict[str, Any],
status: str,
reason: str = "",
ms: int = 0,
):
# 审计日志:建议在生产写入数据库 / ES / 日志平台。
# 这里为了“复制即可跑”,直接 print JSON(一行一条,便于 grep)。
record = {
"trace_id": trace_id,
"user": user,
"role": role,
"tool": tool,
"args": args,
"status": status,
"reason": reason,
"ms": ms,
"ts": round(time.time(), 3),
}
print(json.dumps(record, ensure_ascii=False))
class UserRateLimiter:
# 用户维度限流(滑动窗口):
# - window_seconds:窗口大小(秒)
# - max_calls:窗口内允许的最大调用数
# 说明:这是“系统保护”的第一道门(防刷、防爆)。
def __init__(self, max_calls: int, window_seconds: float):
self.max_calls = max_calls
self.window_seconds = window_seconds
self.calls: Dict[str, Deque[float]] = {}
def allow(self, user: str) -> bool:
# deque 里存该 user 的历史调用时间戳;每次调用先淘汰窗口外的数据。
now = time.time()
q = self.calls.setdefault(user, deque())
while q and now - q[0] > self.window_seconds:
q.popleft()
if len(q) >= self.max_calls:
return False
q.append(now)
return True
def inspect_schema(_: Dict[str, Any]) -> Dict[str, Any]:
# 示例“低风险工具”:查看 schema。
# 要点:工具函数签名统一为 fn(args) -> json-serializable 输出。
return {"columns": ["date", "channel", "gmv", "orders"], "rows": 1000}
def export_report(args: Dict[str, Any]) -> Dict[str, Any]:
# 示例“高风险工具”:导出报告。
# 在企业里 export 往往涉及:大范围数据 + 敏感字段 + 外发链路。
fmt = args.get("format", "csv")
return {"export": "ok", "format": fmt, "url": "https://example.com/report"}
# 工具注册中心(最小版):
# - level:调用该工具所需的最低角色等级
# - fn:工具实现
# 生产环境可扩展:参数 schema、超时、计费、owner、风险等级、脱敏策略等。
TOOL_REGISTRY: Dict[str, Dict[str, Any]] = {
"inspect_schema": {"level": "viewer", "fn": inspect_schema},
"export_report": {"level": "admin", "fn": export_report},
}
def check_permission(role: str, tool_name: str) -> bool:
# 权限校验:
# - tool 不存在:直接拒绝
# - role_level >= tool_required_level:允许
tool = TOOL_REGISTRY.get(tool_name)
if not tool:
return False
return ROLE_LEVELS.get(role, 0) >= ROLE_LEVELS.get(tool["level"], 999)
# 限流器:这里设置为 1 秒内最多 3 次,仅用于演示。
# 实战里你会按:用户等级/接口/工具/全局分别限流,且通常配合 Redis。
rate_limiter = UserRateLimiter(max_calls=3, window_seconds=1.0)
def guarded_call(user: str, role: str, tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
# 统一的“受保护工具调用入口”(治理核心):
# 1) 生成 trace_id(全链路追踪)
# 2) 限流:超限直接 deny + 审计
# 3) 鉴权:无权限直接 deny + 审计
# 4) 执行工具:allow + 审计(含耗时)
trace_id = f"t-{uuid.uuid4().hex[:6]}"
t0 = time.time()
if not rate_limiter.allow(user):
audit_log(trace_id=trace_id, user=user, role=role, tool=tool, args=args, status="deny", reason="rate_limit")
return {"ok": False, "error": "rate_limited", "trace_id": trace_id}
if not check_permission(role, tool):
audit_log(trace_id=trace_id, user=user, role=role, tool=tool, args=args, status="deny", reason="permission")
return {"ok": False, "error": "permission_denied", "trace_id": trace_id}
fn: Callable[[Dict[str, Any]], Any] = TOOL_REGISTRY[tool]["fn"]
out = fn(args)
audit_log(
trace_id=trace_id,
user=user,
role=role,
tool=tool,
args=args,
status="allow",
ms=round((time.time() - t0) * 1000),
)
return {"ok": True, "data": out, "trace_id": trace_id}
if __name__ == "__main__":
# 演示输出:
# - 你会看到每次调用都打印一条审计 JSON(allow/deny 都有)
# - 同一用户短时间连续调用会触发 rate_limit
print("\n--- viewer 调用 inspect_schema(允许)---")
print(guarded_call("u1", "viewer", "inspect_schema", {}))
print("\n--- viewer 调用 export_report(拒绝:权限不足,deny 也审计)---")
print(guarded_call("u1", "viewer", "export_report", {"format": "pdf"}))
print("\n--- admin 调用 export_report(允许)---")
print(guarded_call("admin_1", "admin", "export_report", {"format": "pdf"}))
print("\n--- 同一用户快速连续调用(触发限流)---")
for _ in range(5):
print(guarded_call("u2", "viewer", "inspect_schema", {}))import os
import logging
from langchain.agents import create_agent
from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware
from langchain_core.tools import tool
from langchain_community.chat_models import ChatTongyi
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
# 配置日志
logging.basicConfig(level=logging.INFO)
# ============================
# 定义高危工具:数据导出
# ============================
@tool
def export_salary_report(department: str, format: str = "csv") -> str:
"""导出部门薪资报告(高危工具)。
Args:
department: 部门名称,如 "IT"、"Sales"
format: 导出格式,支持 "csv" 或 "excel"
Returns:
导出结果描述
"""
# 模拟导出操作(真实场景会访问数据库并生成文件)
print(f"[EXPORT] 正在导出 {department} 部门的薪资报告,格式:{format}")
return f"已成功导出 {department} 部门薪资报告(格式:{format}),包含敏感字段:姓名、工号、薪资、手机号"
@tool
def query_employee_info(employee_id: str) -> str:
"""查询员工基本信息(普通工具,不涉及敏感数据)。
Args:
employee_id: 员工工号
Returns:
员工基本信息
"""
print(f"[QUERY] 查询员工信息:{employee_id}")
return f"员工 {employee_id}:张三,IT部门,入职时间 2020-01-15"
# ============================
# 初始化 LLM 和 Agent
# ============================
api_key = os.getenv("DASHSCOPE_API_KEY")
if not api_key:
raise ValueError("请先在环境变量中设置 DASHSCOPE_API_KEY")
llm = ChatTongyi(model_name="qwen-turbo", dashscope_api_key=api_key, temperature=0)
# 配置人类介入中间件:只对 export_salary_report 工具触发审批
hitl = HumanInTheLoopMiddleware(
interrupt_on={
"export_salary_report": True, # 导出薪资报告需要人工确认
}
)
# 创建 Agent
agent = create_agent(
llm,
tools=[export_salary_report, query_employee_info],
middleware=(hitl,), # 启用人类介入中间件
system_prompt=(
"你是一个数据分析助手。"
"当用户需要导出薪资报告时,必须调用 export_salary_report 工具。"
"当用户需要查询员工信息时,调用 query_employee_info 工具。"
),
debug=True,
checkpointer=MemorySaver(), # 必须配置 checkpointer 才能支持中断后继续
)
# ============================
# 执行流程
# ============================
run_config = {
"configurable": {"thread_id": "salary_export_demo"},
"recursion_limit": 100,
}
# 用户请求:导出 IT 部门薪资报告(会触发人工确认)
state = agent.invoke(
{"messages": [("human", "帮我导出 IT 部门的薪资报告,格式用 CSV")]},
config=run_config
)
# 检查是否触发了人工审批
if "__interrupt__" in state:
req = state["__interrupt__"][0].value
action_req = req["action_requests"][0]
print("\n" + "="*60)
print("🚨 检测到高风险操作,需要人工确认")
print("="*60)
print(f"工具名称: {action_req['name']}")
print(f"工具参数: {action_req['args']}")
print(f"工具描述: {action_req.get('description', 'N/A')}")
print("="*60)
# 人工决策
decision = input("\n请选择操作 (approve/reject/edit): ").strip().lower()
if decision == "approve":
print("\n✅ 已批准,正在执行工具调用...")
cmd = Command(resume={"decisions": [{"type": "approve"}]})
elif decision == "reject":
print("\n❌ 已拒绝,工具不会执行")
cmd = Command(resume={"decisions": [{"type": "reject", "message": "人工拒绝执行"}]})
elif decision == "edit":
# 示例:修改导出格式为 excel
print("\n✏️ 已编辑,修改导出格式为 excel")
edited_action = {
"name": action_req["name"],
"args": {"department": action_req["args"]["department"], "format": "excel"},
}
cmd = Command(resume={"decisions": [{"type": "edit", "edited_action": edited_action}]})
else:
print("\n⚠️ 无效决策,默认拒绝")
cmd = Command(resume={"decisions": [{"type": "reject", "message": "未给出有效决策"}]})
# 继续执行
state = agent.invoke(cmd, config=run_config)
# 输出最终结果
print("\n" + "="*60)
print("📊 最终结果")
print("="*60)
print(state["messages"][-1].content)
print("="*60)
# ============================
# 验证要点
# ============================
print("\n✅ 验证要点:")
print("1. 调用 export_salary_report 时会触发人工确认")
print("2. 选择 approve 后工具会执行,可以看到 [EXPORT] 日志")
print("3. 选择 reject 后工具不会执行,不会看到 [EXPORT] 日志")
print("4. 选择 edit 后可以修改工具参数(如导出格式)")
print("5. 调用 query_employee_info 不会触发确认,直接执行")
print("\n💡 扩展建议:")
print("- 可以在 interrupt_on 中添加更多高危工具(如 delete_data、update_schema)")
print("- 可以结合审计日志记录所有确认/拒绝操作")
print("- 可以配置不同角色的审批权限(如普通用户只能查询,管理员才能导出)")interrupt_on={"export_salary_report": True},只对指定工具触发审批if "__interrupt__" in state 判断是否触发了人工审批import os
import time
import logging
from typing import Dict, List
from langchain.agents import create_agent
from langchain.agents.middleware.types import AgentMiddleware, ToolCallRequest
from langchain_core.tools import tool
from langchain_community.chat_models import ChatTongyi
from langgraph.checkpoint.memory import MemorySaver
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
# ============================
# 自定义 Middleware:日志记录
# ============================
class ToolLoggingMiddleware(AgentMiddleware):
"""记录每次工具调用的详细信息(工具名、参数、结果)"""
def __init__(self, logger_name: str = "mw.tools", include_args: bool = True) -> None:
super().__init__()
self.logger = logging.getLogger(logger_name)
self.include_args = include_args
self.call_history: List[Dict] = [] # 保存所有调用历史
def wrap_tool_call(self, request: ToolCallRequest, handler):
tool_call = request.tool_call or {}
name = tool_call.get("name")
args = tool_call.get("args")
# 记录工具调用开始
prefix = "[ToolLoggingMiddleware]"
if self.include_args:
print(f"{prefix} 🔧 tool_call name={name} args={args}")
self.logger.info("tool_call name=%s args=%s", name, args)
else:
print(f"{prefix} 🔧 tool_call name={name}")
self.logger.info("tool_call name=%s", name)
# 执行工具
start_time = time.time()
try:
result = handler(request)
success = True
error_msg = None
except Exception as e:
result = None
success = False
error_msg = str(e)
print(f"{prefix} ❌ tool_error name={name} error={error_msg}")
self.logger.error("tool_error name=%s error=%s", name, error_msg)
raise # 重新抛出异常
duration_ms = int((time.time() - start_time) * 1000)
# 记录工具调用结果
print(f"{prefix} ✅ tool_result name={name} type={type(result).__name__} duration={duration_ms}ms")
self.logger.info("tool_result name=%s type=%s duration=%sms", name, type(result).__name__, duration_ms)
# 保存到历史记录
self.call_history.append({
"tool_name": name,
"args": args if self.include_args else None,
"success": success,
"error_msg": error_msg,
"duration_ms": duration_ms,
"timestamp": time.time()
})
return result
def get_call_history(self) -> List[Dict]:
"""获取所有工具调用历史"""
return self.call_history
# ============================
# 自定义 Middleware:性能监控
# ============================
class ToolTimingMiddleware(AgentMiddleware):
"""记录每次工具调用的耗时,识别性能瓶颈"""
def __init__(self, logger_name: str = "mw.timing", threshold_ms: int = 100) -> None:
super().__init__()
self.logger = logging.getLogger(logger_name)
self.threshold_ms = threshold_ms
self.timing_stats: Dict[str, List[int]] = {} # 保存每个工具的耗时统计
def wrap_tool_call(self, request: ToolCallRequest, handler):
tool_call = request.tool_call or {}
name = tool_call.get("name")
# 记录开始时间
start = time.perf_counter()
result = handler(request)
cost_ms = int((time.perf_counter() - start) * 1000)
# 保存耗时统计
if name not in self.timing_stats:
self.timing_stats[name] = []
self.timing_stats[name].append(cost_ms)
# 只记录超过阈值的调用
prefix = "[ToolTimingMiddleware]"
if cost_ms >= self.threshold_ms:
print(f"{prefix} ⚡ tool_timing name={name} cost_ms={cost_ms} (超过阈值 {self.threshold_ms}ms)")
self.logger.warning("tool_timing name=%s cost_ms=%s threshold=%s", name, cost_ms, self.threshold_ms)
else:
print(f"{prefix} ⚡ tool_timing name={name} cost_ms={cost_ms}")
self.logger.info("tool_timing name=%s cost_ms=%s", name, cost_ms)
return result
def get_performance_summary(self) -> Dict[str, Dict]:
"""获取性能统计摘要"""
summary = {}
for tool_name, timings in self.timing_stats.items():
if timings:
summary[tool_name] = {
"total_calls": len(timings),
"avg_ms": sum(timings) / len(timings),
"min_ms": min(timings),
"max_ms": max(timings),
"total_ms": sum(timings)
}
return summary
# ============================
# 定义数据分析工具
# ============================
@tool
def check_schema(table_name: str) -> str:
"""检查数据表结构(模拟工具)"""
time.sleep(0.1) # 模拟耗时
return f"表 {table_name} 包含字段:id, name, department, salary, hire_date"
@tool
def calculate_metrics(metric_name: str, group_by: str = None) -> str:
"""计算指标(模拟工具)"""
time.sleep(0.2) # 模拟耗时
if group_by:
return f"已计算 {metric_name},按 {group_by} 分组:IT部门平均值=15000, Sales部门平均值=12000"
return f"已计算 {metric_name}:总体平均值=13500"
@tool
def generate_chart(chart_type: str, data_source: str) -> str:
"""生成图表(模拟工具)"""
time.sleep(0.15) # 模拟耗时
return f"已生成 {chart_type} 图表,数据来源:{data_source},保存路径:/tmp/chart_{chart_type}.png"
# ============================
# 初始化 LLM 和 Agent
# ============================
api_key = os.getenv("DASHSCOPE_API_KEY")
if not api_key:
raise ValueError("请先在环境变量中设置 DASHSCOPE_API_KEY")
llm = ChatTongyi(model_name="qwen-turbo", dashscope_api_key=api_key, temperature=0)
# 创建 middleware 实例
tool_logger = ToolLoggingMiddleware(logger_name="mw.tools", include_args=True)
tool_timer = ToolTimingMiddleware(logger_name="mw.timing", threshold_ms=100)
# 创建 Agent(middleware 链:日志 + 性能监控)
agent = create_agent(
llm,
tools=[check_schema, calculate_metrics, generate_chart],
middleware=(tool_logger, tool_timer), # 组合多个 middleware
system_prompt=(
"你是一个数据分析助手。"
"当用户需要分析数据时,先检查表结构,然后计算指标,最后生成图表。"
),
debug=True,
checkpointer=MemorySaver(),
)
# ============================
# 执行分析任务
# ============================
run_config = {
"configurable": {"thread_id": "data_analysis_demo"},
"recursion_limit": 100,
}
print("="*60)
print("开始执行数据分析任务")
print("="*60)
state = agent.invoke(
{"messages": [("human", "分析员工薪资数据:先检查 employees 表结构,然后计算各部门平均薪资,最后生成柱状图")]},
config=run_config
)
print("\n" + "="*60)
print("📊 最终结果")
print("="*60)
print(state["messages"][-1].content)
# ============================
# 输出可观测性统计
# ============================
print("\n" + "="*60)
print("📈 工具调用历史(来自 ToolLoggingMiddleware)")
print("="*60)
import json
print(json.dumps(tool_logger.get_call_history(), ensure_ascii=False, indent=2))
print("\n" + "="*60)
print("⚡ 性能统计摘要(来自 ToolTimingMiddleware)")
print("="*60)
perf_summary = tool_timer.get_performance_summary()
print(json.dumps(perf_summary, ensure_ascii=False, indent=2))
# 识别性能瓶颈
if perf_summary:
bottleneck = max(perf_summary.items(), key=lambda x: x[1]["avg_ms"])
print(f"\n🔍 性能瓶颈:{bottleneck[0]} (平均耗时 {bottleneck[1]['avg_ms']:.1f}ms)")
# ============================
# 验证要点
# ============================
print("\n" + "="*60)
print("✅ 验证要点")
print("="*60)
print("1. ToolLoggingMiddleware 记录了所有工具调用的详细信息(名称、参数、结果、耗时)")
print("2. ToolTimingMiddleware 记录了每次调用的耗时,并识别超过阈值的慢调用")
print("3. 可以通过 get_call_history() 获取完整调用历史,用于错误回放")
print("4. 可以通过 get_performance_summary() 获取性能统计,识别瓶颈")
print("5. Middleware 是可组合的,可以同时启用多个(日志 + 性能 + 人工审批)")
print("\n💡 扩展建议:")
print("- 可以将 call_history 保存到数据库(如 Elasticsearch)用于全链路追踪")
print("- 可以添加错误分类逻辑(数据错误/逻辑错误/系统错误)")
print("- 可以集成 APM 平台(如 Datadog、Prometheus)进行实时监控")
print("- 可以添加告警机制(如耗时超过 P95 基线时发送通知)")# ============================
# 目的:前端渲染层“按 JSON Schema 自适应渲染”
#
# 设计点:
# - Agent 输出统一 JSON(answer/table/bar/line/scatter...)
# - UI 层不关心“是哪个工具做的”,只按字段渲染
# - 这样可以做到:新增一个图表类型,只要新增一个字段即可
# ============================
def render_response(response_dict: dict):
"""根据 JSON Schema 字段自适应选择渲染组件"""
if "answer" in response_dict:
st.success("**回答:**")
st.write(response_dict["answer"])
if "table" in response_dict:
df = pd.DataFrame(
response_dict["table"]["data"],
columns=response_dict["table"]["columns"]
)
st.dataframe(df, use_container_width=True)
for chart_type in ["bar", "line", "scatter"]:
if chart_type in response_dict:
create_chart(response_dict[chart_type], chart_type)项目按 7 大核心能力 将业务逻辑拆解为独立类,main.py 通过门面类统一调用:
项目五:「数析」智能数据分析台/
├── main.py # Streamlit 界面(依赖 DataframeAgentFacade)
├── utils.py # 7 大核心能力(按类拆解)
│ ├── ToolMeta # 工具元数据(名称/权限/超时)
│ ├── ToolRegistry # ① 工具注册中心(注册/鉴权/调用计数)
│ ├── AnalysisPlan # 结构化分析计划(数据类)
│ ├── PlanEngine # ② 自然语言 → 结构化计划(LLM 第一阶段)
│ ├── RiskGuard # ③ 风险评估(高危工具/敏感字段/大导出)
│ ├── AuditEvent # 审计事件(数据类)
│ ├── AuditLogger # ④ 人机协同审核 + 审计日志
│ ├── DataAnalyzer # ⑤ ReAct 执行层(逐步调用工具 + LLM 第二阶段)
│ ├── QualityOptimizer # ⑥ 复盘分析 + 优化建议
│ ├── ReportExporter # ⑦ HTML 报告生成
│ └── DataframeAgentFacade# 门面类:统一编排以上 7 大模块
├── requirements.txt # 项目依赖
├── house_price.csv # 示例数据:房价数据(545 条)
└── personal_data.csv # 示例数据:个人消费数据(22 条)用户界面 (Streamlit / main.py)
↓ facade.analyze(df, query, human_confirmed)
DataframeAgentFacade
├─① PlanEngine.parse() ← 自然语言 → AnalysisPlan(LLM 第一阶段)
├─② RiskGuard.assess() ← 评估风险等级(high / low)
├─③ AuditLogger.record() ← 记录 cancelled(高风险未确认则阻断)
├─④ DataAnalyzer.execute() ← 逐步调用 ToolRegistry 中的工具
│ └─ LLM 第二阶段 ← 生成最终 JSON 结果
├─⑤ AuditLogger.record() ← 记录 executed / failed
└─⑥ ReportExporter(可选) ← 生成 HTML 报告(含审计日志)
↓
render_result() ← 自动分发 answer/table/bar/line/scatter
↓
QualityOptimizer.suggest() ← 按需输出优化建议(基于审计日志)# 安装依赖:建议在虚拟环境中执行,避免污染系统 Python
python -m pip install -U streamlit>=1.32.0 pandas==2.2.3 \
langchain-classic==1.0.0 "langchain-core>=1,<2" "langchain-community>=0.3,<0.4" \
"langchain-text-splitters>=1,<2" "langsmith>=0.1.17,<1" dashscope python-dotenv>=1.0.0或使用 requirements.txt:
# requirements.txt(建议直接保存为 requirements.txt 使用)
#
# 说明:
# - 这里把关键依赖“锁版本区间”,避免因为上游升级导致示例无法运行
# - 如果你使用不同版本的 LangChain/LangGraph,请以你本地可跑版本为准
# - dashscope 负责调用通义千问 API
streamlit>=1.32.0
pandas==2.2.3
# LangChain 依赖(版本锁定,避免"在我电脑上能跑")
langchain-classic==1.0.0
langchain-core>=1,<2
langchain-community>=0.3,<0.4
langchain-text-splitters>=1,<2
langsmith>=0.1.17,<1
# 通义千问(DashScope)SDK
dashscope
# 可选:从 .env 加载环境变量
python-dotenv>=1.0.0访问 阿里云 DashScope 控制台 获取 API Key
按 7 大核心能力拆解为独立类,各司其职:ToolRegistry(工具注册)、PlanEngine(计划引擎)、RiskGuard(风险守卫)、AuditLogger(审计日志)、DataAnalyzer(数据分析)、QualityOptimizer(质量优化)、ReportExporter(报告导出),最终由 DataframeAgentFacade 统一编排。
"""utils.py — 「数析」智能数据分析台核心模块
本文件的目标:把“LLM + Pandas”的一次性脚本,升级为可讲解、可扩展、可上线的组件化系统。
核心思想:
- **LLM 负责理解自然语言**:把用户问题解析为一个结构化执行计划(AnalysisPlan)。
- **代码负责真实执行**:根据计划调用 Pandas 工具链,得到确定的表格/图表数据。
- **统一输出协议**:最终返回一个 JSON dict,交给界面层(main.py)渲染。
你可以把它理解为一个极简版的“Agent 系统”:
- Planning(PlanEngine):自然语言 -> steps(工具调用序列)
- Guard(RiskGuard):识别高风险/敏感字段,决定是否拦截
- Execute(DataAnalyzer):按 steps 执行工具链(DataFrame pipeline)
- Audit(AuditLogger):记录每次请求的 trace_id、状态、用过哪些工具
按 7 大核心能力拆解:
1. ToolRegistry — 工具注册中心(有哪些工具、怎么调用、调用计数)
2. PlanEngine — LLM 规划器(自然语言 -> 结构化计划 AnalysisPlan)
3. RiskGuard — 风险识别(高危工具/敏感字段/大导出)
4. AuditLogger — 审计留痕(人机协同 + 复盘)
5. DataAnalyzer — 执行器(按计划真实跑 Pandas 计算并产出结构化结果)
6. QualityOptimizer — 质量优化建议(基于审计日志的简单复盘)
7. ReportExporter — 报告导出(HTML 报告字符串)
统一输出协议(供 main.py 渲染):
- 纯文本:{"answer": "..."}
- 表格: {"table": {"columns": [...], "data": [[...], ...]}}
- 条形图:{"bar": {"columns": [x_col, y_col], "data": [[x,y], ...]}}
- 折线图:{"line": {"x": "x列名", "y": "y列名", "columns": [...], "data": [[x,y], ...]}}
- 散点图:{"scatter":{...}}
并附带元信息:
- "_trace_id":一次请求的追踪 ID
- "_executed_tools":本次执行过的工具列表
"""
# ============================
# 说明:如何把 Pandas 能力“产品化”?
#
# 关键不是写脚本,而是把能力拆成可控组件:
# - ToolRegistry:工具注册/权限/超时/调用计数(治理入口)
# - PlanEngine:自然语言 → 结构化计划(保证口径一致)
# - RiskGuard + AuditLogger:高风险阻断 + 审计留痕(可上线)
# - DataAnalyzer:按计划执行工具链,最终产出统一 JSON(可渲染/可导出)
# - QualityOptimizer:基于审计日志做复盘与优化建议(持续迭代)
# - ReportExporter:把结果做成可交付物(HTML 报告)
#
# 使用方式(界面层 main.py 会这么用):
# facade = DataframeAgentFacade(dashscope_api_key=...)
# resp = facade.analyze(df, query, human_confirmed=False)
# ============================
import json
import logging
import re
import time
import uuid
from dataclasses import dataclass, asdict, field
from typing import Any, Callable, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from langchain_community.chat_models import ChatTongyi
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
)
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────
# 1. ToolRegistry — Agent 架构与工具链设计
# ─────────────────────────────────────────────────────────────
@dataclass
class ToolMeta:
"""工具元数据。
设计目的:让“工具”从散落的函数,变成可治理的资源。
字段解释:
- name: 工具名(会暴露给 LLM 规划器使用)
- description: 给人读的描述(也会进入 LLM prompt,帮助模型选工具)
- allowed_roles: 哪些角色能用(用于风控/权限,当前 demo 默认 analyst)
- timeout_sec: 预留的超时控制(demo 未做强制超时)
- fn: 真实执行函数(签名约定:fn(df, **args) -> DataFrame | dict | str)
"""
name: str
description: str
allowed_roles: List[str] # e.g. ["analyst", "admin"]
timeout_sec: float = 5.0
fn: Optional[Callable] = None
class ToolRegistry:
"""工具注册中心:统一管理工具元数据、权限与调用入口。
你可以把它理解为一个“工具商店”:
- 注册:有哪些工具(ToolMeta)
- 查询:给定 tool_name 找到工具实现
- 计数:记录每个工具被调用了多少次(便于监控/优化)
"""
def __init__(self):
self._registry: Dict[str, ToolMeta] = {}
self._call_counts: Dict[str, int] = {}
self._register_builtin_tools()
def _register_builtin_tools(self):
"""注册内置工具。
关键点:
- 这些工具名会被 PlanEngine 暴露给 LLM,成为“规划”阶段可选 action。
- 每个工具都绑定一个可执行的 fn(真实跑 Pandas 逻辑)。
- 工具的参数 schema 由 PlanEngine 的 prompt 约束,避免“模型乱填参数”。
"""
builtins = [
ToolMeta("df_shape", "返回数据集行数/列数", ["analyst", "admin"], fn=self._tool_df_shape),
ToolMeta("head", "返回前 N 行数据", ["analyst", "admin"], fn=self._tool_head),
ToolMeta("col_mean", "计算某一列的平均值", ["analyst", "admin"], fn=self._tool_col_mean),
ToolMeta("numeric_means", "计算所有数值列的平均值", ["analyst", "admin"], fn=self._tool_numeric_means),
ToolMeta("value_counts", "统计某列各类别数量", ["analyst", "admin"], fn=self._tool_value_counts),
ToolMeta("safe_filter", "按条件过滤数据行", ["analyst", "admin"], fn=self._tool_safe_filter),
ToolMeta("group_agg", "分组聚合统计", ["analyst", "admin"], fn=self._tool_group_agg),
ToolMeta("top_n", "按列排序取 TopN 行", ["analyst", "admin"], fn=self._tool_top_n),
ToolMeta("anomaly_detect", "异常值检测(z-score)", ["analyst", "admin"], fn=self._tool_anomaly_detect),
ToolMeta("plot_bar", "生成条形图数据", ["analyst", "admin"], fn=self._tool_plot_bar),
ToolMeta("plot_line", "生成折线图数据", ["analyst", "admin"], fn=self._tool_plot_line),
ToolMeta("plot_scatter", "生成散点图数据", ["analyst", "admin"], fn=self._tool_plot_scatter),
ToolMeta("export_report", "导出 HTML/PDF 报告", ["admin"]),
ToolMeta("delete_rows", "删除数据行(高危)", ["admin"]),
ToolMeta("update_schema", "修改字段结构(高危)", ["admin"]),
]
for t in builtins:
self._registry[t.name] = t
# ----------------------------
# 内置工具实现(真实执行)
# 约定:工具返回类型可以是:DataFrame / dict(table|bar|line|scatter) / str
# ----------------------------
@staticmethod
def _tool_df_shape(df: pd.DataFrame, **_: Any) -> Dict[str, Any]:
"""返回行列数(回答类输出)。"""
return {"answer": f"数据集共有 {df.shape[0]} 行,{df.shape[1]} 列。"}
@staticmethod
def _tool_head(df: pd.DataFrame, n: int = 10, **_: Any) -> pd.DataFrame:
"""返回前 N 行(DataFrame 输出,便于后续继续 pipeline)。"""
n = int(n)
return df.head(n)
@staticmethod
def _tool_col_mean(df: pd.DataFrame, column: str, **_: Any) -> Dict[str, Any]:
"""计算某一列的均值(回答类输出)。
说明:
- 会做 to_numeric(coerce) 以兼容字符串数字。
- 若全为空/全非数值,则返回可解释的文本。
"""
if column not in df.columns:
return {"answer": f"列 {column} 不存在。"}
s = pd.to_numeric(df[column], errors="coerce")
v = float(s.mean()) if s.notna().any() else None
return {"answer": f"{column} 列的平均值是 {v:.4f}" if v is not None else f"{column} 列无法计算平均值(非数值或全为空)。"}
@staticmethod
def _tool_numeric_means(df: pd.DataFrame, **_: Any) -> Dict[str, Any]:
"""计算所有数值列均值(表格输出)。"""
num_df = df.select_dtypes(include=["number"]) # type: ignore[arg-type]
means = num_df.mean(numeric_only=True).to_dict()
out = pd.DataFrame({"column": list(means.keys()), "mean": list(means.values())})
return {"table": {"columns": ["column", "mean"], "data": out.values.tolist()}}
@staticmethod
def _tool_value_counts(df: pd.DataFrame, column: str, top: int = 50, **_: Any) -> pd.DataFrame:
"""类别分布统计(返回 DataFrame)。
为什么返回 DataFrame:
- 这样可以和 plot_bar 组合:value_counts -> plot_bar
- 如果直接返回 table dict,会“断开” pipeline,不利于组合。
"""
if column not in df.columns:
raise ValueError(f"列 {column} 不存在。")
vc = df[column].astype(str).value_counts(dropna=False).head(int(top))
out = pd.DataFrame({column: vc.index.tolist(), "count": vc.values.tolist()})
# 返回 DataFrame,便于后续 plot_bar 继续消费
return out
@staticmethod
def _tool_safe_filter(df: pd.DataFrame, expr: str, **_: Any) -> pd.DataFrame:
"""安全过滤(pandas.query)。
风险说明:
- query 表达式是字符串,理论上可能被滥用。
- demo 简化处理:只允许 pandas.query 语法,异常抛给上层。
- 生产化可加入:表达式白名单/黑名单、最大行数限制等。
"""
# 只允许 pandas.query 表达式;异常直接抛出给上层。
return df.query(expr)
@staticmethod
def _tool_group_agg(df: pd.DataFrame, by: str, agg: Dict[str, str], **_: Any) -> pd.DataFrame:
"""分组聚合。
参数:
- by: 分组列
- agg: 形如 {"price": "mean", "area": "max"}
"""
if by not in df.columns:
raise ValueError(f"group by 列不存在: {by}")
for col in agg.keys():
if col not in df.columns:
raise ValueError(f"聚合列不存在: {col}")
res = df.groupby(by).agg(agg).reset_index()
return res
@staticmethod
def _tool_top_n(df: pd.DataFrame, n: int = 5, by: Optional[str] = None, ascending: bool = False, **_: Any) -> pd.DataFrame:
"""按某列排序取 TopN。
教学要点:
- LLM 常把“最高/最大/TopN”映射到这个工具
- by 会尽量 to_numeric 做数值排序(price 这类列)
"""
n = int(n)
if by and by in df.columns:
key = pd.to_numeric(df[by], errors="coerce")
tmp = df.copy()
tmp["__sort_key__"] = key
tmp = tmp.sort_values("__sort_key__", ascending=bool(ascending), na_position="last")
tmp = tmp.drop(columns=["__sort_key__"])
return tmp.head(n)
return df.head(n)
@staticmethod
def _tool_anomaly_detect(df: pd.DataFrame, column: str, z_threshold: float = 3.0, top: int = 20, **_: Any) -> Dict[str, Any]:
"""异常值检测:z-score。
输出:table(异常行)
"""
if column not in df.columns:
return {"answer": f"列 {column} 不存在。"}
s = pd.to_numeric(df[column], errors="coerce")
mu = s.mean()
sigma = s.std(ddof=0)
if sigma == 0 or np.isnan(sigma):
return {"answer": f"列 {column} 无法进行异常检测(方差为 0 或无有效数值)。"}
z = (s - mu) / sigma
mask = z.abs() >= float(z_threshold)
out = df.loc[mask].copy().head(int(top))
return {"table": {"columns": [str(c) for c in out.columns], "data": out.astype(object).where(pd.notnull(out), None).values.tolist()}}
@staticmethod
def _tool_plot_bar(df: pd.DataFrame, x: str, y: str = "count", **_: Any) -> Dict[str, Any]:
"""生成条形图数据(bar)。
约定:
- 输入 df 通常来自 value_counts 或 group_agg
- 输出结构为 {"bar": {"columns": [...], "data": [...]}}
"""
# 约定:传入的 df 通常是 value_counts 的结果(两列:x + count)或 group_agg 的结果
if x not in df.columns:
raise ValueError(f"x 列不存在: {x}")
if y not in df.columns:
# 允许把第二列当 y
y = df.columns[1] if len(df.columns) > 1 else y
out = df[[x, y]].copy()
return {"bar": {"columns": [str(x), str(y)], "data": out.values.tolist()}}
@staticmethod
def _tool_plot_line(df: pd.DataFrame, x: str, y: str, **_: Any) -> Dict[str, Any]:
"""生成折线图数据(line)。
特殊语义:
- x="index":使用行号作为 x 轴(当数据集没有时间列时,展示“趋势”常用)
"""
# 支持 x="index":用行号作为趋势横轴
if x == "index":
out = df.copy()
out["index"] = list(range(len(out)))
else:
out = df
if x not in out.columns or y not in out.columns:
raise ValueError(f"折线图列不存在: x={x}, y={y}")
out2 = out[[x, y]].copy()
return {"line": {"x": str(x), "y": str(y), "columns": [str(x), str(y)], "data": out2.values.tolist()}}
@staticmethod
def _tool_plot_scatter(df: pd.DataFrame, x: str, y: str, **_: Any) -> Dict[str, Any]:
"""生成散点图数据(scatter)。
特殊语义:
- x="index":使用行号作为 x 轴
"""
if x == "index":
out = df.copy()
out["index"] = list(range(len(out)))
else:
out = df
if x not in out.columns or y not in out.columns:
raise ValueError(f"散点图列不存在: x={x}, y={y}")
out2 = out[[x, y]].copy()
return {"scatter": {"x": str(x), "y": str(y), "columns": [str(x), str(y)], "data": out2.values.tolist()}}
def register(self, tool: ToolMeta):
self._registry[tool.name] = tool
logger.info(f"[ToolRegistry] 注册工具: {tool.name}")
def get(self, name: str) -> Optional[ToolMeta]:
return self._registry.get(name)
def list_tools(self, role: str = "analyst") -> List[str]:
return [n for n, t in self._registry.items() if role in t.allowed_roles]
def record_call(self, name: str):
self._call_counts[name] = self._call_counts.get(name, 0) + 1
def call_stats(self) -> Dict[str, int]:
return dict(self._call_counts)
# ─────────────────────────────────────────────────────────────
# 2. PlanEngine — 自然语言转结构化计划
# ─────────────────────────────────────────────────────────────
@dataclass
class AnalysisPlan:
trace_id: str
intent: str
steps: List[Dict[str, Any]]
fields: List[str]
output_type: str # answer / table / bar / line / scatter
export_rows: int = 0
class PlanEngine:
"""将自然语言查询转换为结构化 AnalysisPlan,再交由 DataAnalyzer 执行。
这是“Planning”阶段:
- 输入:df 元信息(列名/类型/样本) + 用户自然语言 query
- 输出:AnalysisPlan(intent + steps + output_type)
关键:
- prompt 里必须给清楚“可用工具列表 + 参数 schema”,否则 LLM 很容易输出不可执行的 steps。
- 本 demo 要求 LLM **只输出 JSON**,然后用 _safe_parse 做容错解析。
"""
def __init__(self, model: ChatTongyi, registry: ToolRegistry):
self.model = model
self.registry = registry
def parse(self, df_meta: Dict[str, Any], query: str) -> AnalysisPlan:
"""调用 LLM 生成结构化计划。
返回的计划必须满足:
- steps: [{"tool": "工具名", "args": {...}}]
- output_type: answer/table/bar/line/scatter
注意:
- 这里是 100% 强制走 LLM(按你的要求已移除规则兜底)。
- 课堂演示时如果 API Key 无效,会在这里直接抛错。
"""
allowed = self.registry.list_tools(role="analyst")
prompt = f"""你是数据分析规划师。请将用户问题拆解为结构化 JSON 执行计划,并且严格使用下方工具与参数约定。
DataFrame 信息:
- 形状: {df_meta['shape']}
- 列名: {df_meta['columns']}
- 字段类型: {df_meta['dtypes']}
- 样本(15行): {df_meta['sample']}
可用工具: {allowed}
工具参数约定(非常重要,必须严格遵守;只输出 JSON,不要解释):
1) df_shape
- args: {{}}
- 用途:回答“多少行多少列”
2) head
- args: {{"n": 10}}
- 用途:显示前 N 行
3) col_mean
- args: {{"column": "price"}}
- 用途:某列平均值
4) numeric_means
- args: {{}}
- 用途:所有数值列的平均值(输出 table)
5) value_counts
- args: {{"column": "furnishingstatus", "top": 50}}
- 用途:类别分布统计(输出 table,列为 column + count)
6) safe_filter
- args: {{"expr": "price > 5000000 and area >= 6000"}}
- 用途:按条件过滤(pandas query 语法)
7) group_agg
- args: {{"by": "furnishingstatus", "agg": {{"price": "mean"}}}}
- 用途:分组聚合(agg 的 value 只能是: mean|sum|min|max|count)
8) top_n
- args: {{"n": 5, "by": "price", "ascending": false}}
- 用途:按列排序取 TopN(最高/最大 ascending=false;最低/最小 ascending=true)
9) plot_bar
- args: {{"x": "furnishingstatus", "y": "count"}}
- 用途:将当前 DataFrame 转为 bar 图数据
10) plot_line
- args: {{"x": "index", "y": "price"}} 或 {{"x": "date", "y": "price"}}
- 用途:折线图(必须确保 x/y 在当前 DataFrame 中存在)
11) plot_scatter
- args: {{"x": "area", "y": "price"}}
- 用途:散点图
用户问题: {query}
输出格式(仅返回 JSON,不要其他文字):
{{
"intent": "一句话概括用户意图",
"steps": [
{{"tool": "工具名", "args": {{"参数名": "参数值"}}}}
],
"fields": ["涉及字段列表"],
"output_type": "answer|table|bar|line|scatter",
"export_rows": 0
}}"""
raw = self.model.invoke(prompt).content
data = self._safe_parse(raw)
return AnalysisPlan(
trace_id="t-" + uuid.uuid4().hex[:8],
intent=data.get("intent", query),
steps=data.get("steps", []),
fields=data.get("fields", []),
output_type=data.get("output_type", "answer"),
export_rows=data.get("export_rows", 0),
)
@staticmethod
def _safe_parse(raw: str) -> Dict[str, Any]:
"""尽可能把模型输出解析成 dict。
兼容几类常见异常输出:
- 正常 JSON:{"a":1}
- JSON 外围带杂字符:xxx {...} yyy
- 多个 JSON 拼接:{"answer":"..."}{"table":{...}}
"""
if not isinstance(raw, str):
return {}
s = raw.strip()
if not s:
return {}
# 1) 直接尝试解析
try:
obj = json.loads(s)
return obj if isinstance(obj, dict) else {}
except Exception:
pass
# 2) 尝试从文本中抽取第一个 {...}
m = re.search(r"\{.*\}", s, re.DOTALL)
if m:
blob = m.group()
try:
obj = json.loads(blob)
return obj if isinstance(obj, dict) else {}
except Exception:
pass
# 3) 解析“多个 JSON 对象拼接”的情况:{...}{...}
# 用 JSONDecoder.raw_decode 逐段解析并 merge
dec = json.JSONDecoder()
i = 0
merged: Dict[str, Any] = {}
while i < len(s):
# 找到下一个 '{'
j = s.find('{', i)
if j == -1:
break
try:
obj, end = dec.raw_decode(s, j)
if isinstance(obj, dict):
merged.update(obj)
i = end
except Exception:
i = j + 1
return merged
# ─────────────────────────────────────────────────────────────
# 3. RiskGuard — 工具治理与安全边界
# ─────────────────────────────────────────────────────────────
HIGH_RISK_TOOLS = {"export_report", "delete_rows", "update_schema"}
SENSITIVE_FIELDS = {"phone", "id_card", "salary", "password", "bank_account"}
LARGE_EXPORT_ROWS = 1000
class RiskGuard:
"""评估计划风险等级;阻断未授权的高危操作。"""
def assess(self, plan: AnalysisPlan) -> Tuple[str, List[str]]:
factors: List[str] = []
tools_used = {s.get("tool", "") for s in plan.steps}
fields_used = set(plan.fields)
if tools_used & HIGH_RISK_TOOLS:
factors.append("high_risk_tools=" + ",".join(sorted(tools_used & HIGH_RISK_TOOLS)))
if fields_used & SENSITIVE_FIELDS:
factors.append("sensitive_fields=" + ",".join(sorted(fields_used & SENSITIVE_FIELDS)))
if plan.export_rows >= LARGE_EXPORT_ROWS:
factors.append(f"large_export_rows={plan.export_rows}")
level = "high" if factors else "low"
return level, factors
def validate_tools(self, plan: AnalysisPlan, registry: ToolRegistry) -> List[str]:
"""返回计划中未注册的工具名(空列表表示合法)。"""
all_tools = set(registry._registry.keys())
used = {s.get("tool", "") for s in plan.steps}
return sorted(used - all_tools)
# ─────────────────────────────────────────────────────────────
# 4. AuditLogger — 人机协同审核 + 审计日志
# ─────────────────────────────────────────────────────────────
@dataclass
class AuditEvent:
trace_id: str
status: str # pending / approved / cancelled / executed / failed
risk_level: str
risk_factors: List[str]
intent: str
executed_tools: List[str] = field(default_factory=list)
result_keys: List[str] = field(default_factory=list)
ts: float = field(default_factory=time.time)
class AuditLogger:
"""记录每次分析的审核状态与执行结果,支持复盘与质量回顾。"""
def __init__(self):
self._log: List[AuditEvent] = []
def record(self, event: AuditEvent):
self._log.append(event)
logger.info(f"[Audit] {event.trace_id} status={event.status} risk={event.risk_level}")
def all_events(self) -> List[Dict[str, Any]]:
return [asdict(e) for e in self._log]
def cancelled_count(self) -> int:
return sum(1 for e in self._log if e.status == "cancelled")
def executed_count(self) -> int:
return sum(1 for e in self._log if e.status == "executed")
# ─────────────────────────────────────────────────────────────
# 5. DataAnalyzer — 数据分析能力集成(ReAct 执行层)
# ─────────────────────────────────────────────────────────────
class DataAnalyzer:
"""依据 AnalysisPlan 逐步执行工具调用,返回最终 JSON 结果。"""
def __init__(self, model: ChatTongyi, registry: ToolRegistry):
self.model = model
self.registry = registry
def execute(self, plan: AnalysisPlan, df: pd.DataFrame, df_meta: Dict[str, Any]) -> Dict[str, Any]:
"""执行计划(Execute 阶段)。
核心机制:DataFrame pipeline
- work_df:当前工作 DataFrame(初始为原始 df)
- 每一步调用一个工具:out = tool.fn(work_df, **args)
- 如果 out 是 DataFrame:更新 work_df(用于下一步继续处理)
- 如果 out 是 dict/str:视为“结构化结果”或“文本答案”,不更新 work_df
最终结果的选择规则:
- 如果最后一次工具输出是 dict 且包含 answer/table/bar/line/scatter,直接作为最终结果
- 否则按 plan.output_type 做默认输出(通常是把 work_df 转 table)
这样设计的好处:
- LLM 只负责选工具/传参,不负责“编数据”
- 工具链可组合(例如 value_counts -> plot_bar)
- 输出结构稳定,渲染层简单
"""
executed_tools: List[str] = []
work_df: pd.DataFrame = df
last_obj: Any = None
def _df_to_table(d: pd.DataFrame, limit: int = 200) -> Dict[str, Any]:
dd = d.head(limit)
cols = [str(c) for c in dd.columns]
data = dd.astype(object).where(pd.notnull(dd), None).values.tolist()
return {"table": {"columns": cols, "data": data}}
for step in plan.steps:
tool_name = step.get("tool", "")
args = step.get("args", {}) or {}
meta = self.registry.get(tool_name)
if meta is None or meta.fn is None:
logger.warning(f"[DataAnalyzer] 跳过未注册/无实现工具: {tool_name}")
continue
self.registry.record_call(tool_name)
executed_tools.append(tool_name)
logger.info(f"[DataAnalyzer] 执行工具: {tool_name} args={args}")
# 统一调用:工具默认以当前 work_df 为输入
out = meta.fn(work_df, **args)
last_obj = out
# pipeline 规则:返回 DataFrame 则更新 work_df;返回 dict/str 则不更新
if isinstance(out, pd.DataFrame):
work_df = out
# 组装最终结果(只要是 dict 且含结构化 key,就直接返回)
result: Dict[str, Any]
if isinstance(last_obj, dict) and any(k in last_obj for k in ("answer", "table", "bar", "line", "scatter")):
result = dict(last_obj)
elif isinstance(last_obj, str):
result = {"answer": last_obj}
else:
# 根据 output_type 决定默认输出
if plan.output_type in ("bar", "line", "scatter"):
# 若 LLM 没输出 plot_*,兜底为 table(避免前端空白)
result = _df_to_table(work_df)
elif plan.output_type == "table":
result = _df_to_table(work_df)
else:
result = {"answer": "已完成分析,但未生成可展示的结构化结果。"}
result["_executed_tools"] = executed_tools
result["_trace_id"] = plan.trace_id
return result
def _call_llm_for_result(self, plan: AnalysisPlan, df_meta: Dict[str, Any]) -> Dict[str, Any]:
prompt = f"""你是数据分析执行助手。数据已加载为 Pandas DataFrame。
DataFrame 信息:
- 形状: {df_meta['shape']}
- 列名: {df_meta['columns']}
- 字段类型: {df_meta['dtypes']}
- 样本(15行): {df_meta['sample']}
分析意图: {plan.intent}
执行步骤: {json.dumps(plan.steps, ensure_ascii=False)}
期望输出类型: {plan.output_type}
输出格式(仅返回 JSON,不要其他文字):
- 文字回答: {{"answer": "答案文字"}}
- 表格: {{"table": {{"columns": ["列1","列2"], "data": [[v1,v2]]}}}}
- 条形图: {{"bar": {{"columns": ["类别","值"], "data": [["A",10]]}}}}
- 折线图: {{"line": {{"x":"x轴真实列名","y":"y轴真实列名","columns":["x轴列名","y轴列名"],"data":[[x,y]]}}}}
- 散点图: {{"scatter":{{"x":"x轴真实列名","y":"y轴真实列名","columns":["x轴列名","y轴列名"],"data":[[x,y]]}}}}
重要:columns 中必须填写数据集中真实存在的列名,不能用 "x"/"y" 等占位符。
只返回 JSON,不要代码,不要解释。"""
raw = self.model.invoke(prompt).content
# LLM 偶发会返回被转义的 JSON(例如 {\"table\":...} 或 "{...}"),这里做一次反转义/二次解析兜底
raw_str = raw.strip() if isinstance(raw, str) else str(raw)
result = PlanEngine._safe_parse(raw_str)
if not result and raw_str.startswith('"') and raw_str.endswith('"'):
try:
inner = json.loads(raw_str)
if isinstance(inner, str):
result = PlanEngine._safe_parse(inner)
except Exception:
pass
if not result and raw_str.startswith("{\\\""):
result = PlanEngine._safe_parse(raw_str.replace('\\"', '"'))
result = result or {"answer": raw_str}
# 若同时返回了 answer + 结构化数据,删除 answer 避免展示层渲染原始 JSON
if "answer" in result and any(k in result for k in ("table", "bar", "line", "scatter")):
result.pop("answer")
return result
# ─────────────────────────────────────────────────────────────
# 6. QualityOptimizer — 数据复盘与质量优化
# ─────────────────────────────────────────────────────────────
class QualityOptimizer:
"""分析审计日志,输出高频错误类型和优化建议。"""
def __init__(self, audit: AuditLogger):
self.audit = audit
def suggest(self) -> List[str]:
events = self.audit.all_events()
if not events:
return ["暂无分析记录,无法给出建议。"]
suggestions: List[str] = []
failed = [e for e in events if e["status"] == "failed"]
cancelled = [e for e in events if e["status"] == "cancelled"]
if len(cancelled) > 0:
suggestions.append(f"有 {len(cancelled)} 次因高风险被拦截,建议审查敏感字段权限或降低导出量。")
if len(failed) > 0:
suggestions.append(f"有 {len(failed)} 次执行失败,建议检查 Prompt 模板或数据字段名。")
if not suggestions:
suggestions.append("执行质量良好,无明显异常。")
return suggestions
# ─────────────────────────────────────────────────────────────
# 7. ReportExporter — 可视化与报告生成
# ─────────────────────────────────────────────────────────────
class ReportExporter:
"""将分析结果转换为可下载的 HTML 报告字符串。"""
def export_html(self, query: str, result: Dict[str, Any], audit_events: List[Dict]) -> str:
rows = ""
if "answer" in result:
rows += f"回答:{result['answer']}"
if "table" in result:
t = result["table"]
header = "".join(f"{c}" for c in t.get("columns", []))
body = "".join(
"" + "".join(f"{v}" for v in row) + ""
for row in t.get("data", [])
)
rows += f"{header}{body}"
trace_id = result.get("_trace_id", "N/A")
tools = ", ".join(result.get("_executed_tools", []))
return f"""
数析报告
「数析」智能数据分析报告
查询:{query}
Trace ID:{trace_id} | 执行工具:{tools}
{rows}
审计日志
{json.dumps(audit_events, ensure_ascii=False, indent=2)}
"""
# ─────────────────────────────────────────────────────────────
# 门面:DataframeAgentFacade — 统一编排 7 大模块
# ─────────────────────────────────────────────────────────────
class DataframeAgentFacade:
"""对外暴露单一入口 analyze(),内部编排全部 7 个模块。"""
def __init__(self, dashscope_api_key: str):
# 初始化大模型(生产环境建议加超时/重试/成本控制/熔断,这里略)
self.model = ChatTongyi(
model="qwen-turbo",
dashscope_api_key=dashscope_api_key,
temperature=0,
)
# 注册中心:统一管理工具、权限、超时、调用计数等
self.registry = ToolRegistry()
# 计划引擎:把自然语言 query 转成结构化 AnalysisPlan
self.plan_eng = PlanEngine(self.model, self.registry)
# 风险守卫:识别高危工具/敏感字段/大导出等
self.risk = RiskGuard()
# 审计记录:记录 cancelled/executed/failed
self.audit = AuditLogger()
# 执行器:按 plan.steps 调用工具,并最终让模型产出统一 JSON
self.analyzer = DataAnalyzer(self.model, self.registry)
# 优化器:基于审计日志输出可操作建议
self.optimizer = QualityOptimizer(self.audit)
# 导出器:把结果渲染成 HTML 报告
self.exporter = ReportExporter()
def analyze(
self,
df,
query: str,
human_confirmed: bool = False,
) -> Dict[str, Any]:
import pandas as pd
df_meta = {
"shape": df.shape,
"columns": list(df.columns),
"dtypes": df.dtypes.astype(str).to_dict(),
"sample": df.sample(n=min(15, len(df)), random_state=42).to_string(index=False),
}
# ① 生成结构化计划(第一阶段:Planning)
# 说明:这里会调用 LLM(通义千问)来产生可执行的 steps。
plan = self.plan_eng.parse(df_meta, query)
logger.info(f"[Facade] Plan: intent={plan.intent} steps={len(plan.steps)}")
# ② 风险评估:确定是否需要人工确认、是否包含未注册工具
# - 风险等级 high:包含高危工具/敏感字段/大导出
# - invalid_tools:LLM 可能输出了不存在的工具名(这里会记录 warning)
risk_level, factors = self.risk.assess(plan)
invalid_tools = self.risk.validate_tools(plan, self.registry)
if invalid_tools:
logger.warning(f"[Facade] 未注册工具: {invalid_tools}")
# ③ 人机协同门控:高风险需要人工确认(Human-in-the-Loop)
if risk_level == "high" and not human_confirmed:
self.audit.record(AuditEvent(
trace_id=plan.trace_id,
status="cancelled",
risk_level=risk_level,
risk_factors=factors,
intent=plan.intent,
))
return {
"blocked": True,
"trace_id": plan.trace_id,
"reason": "high_risk_not_confirmed",
"risk_factors": factors,
}
# ④ 执行分析(第二阶段:Execute)
# - 真正执行工具链(Pandas 计算)
# - 写入审计日志(executed/failed)
try:
result = self.analyzer.execute(plan, df, df_meta)
self.audit.record(AuditEvent(
trace_id=plan.trace_id,
status="executed",
risk_level=risk_level,
risk_factors=factors,
intent=plan.intent,
executed_tools=result.get("_executed_tools", []),
result_keys=list(result.keys()),
))
except Exception as e:
self.audit.record(AuditEvent(
trace_id=plan.trace_id,
status="failed",
risk_level=risk_level,
risk_factors=factors,
intent=plan.intent,
))
raise ValueError(f"执行分析时出错: {e}") from e
return result
def export_report(self, query: str, result: Dict[str, Any]) -> str:
return self.exporter.export_html(query, result, self.audit.all_events())
def quality_suggestions(self) -> List[str]:
return self.optimizer.suggest()
# ─────────────────────────────────────────────────────────────
# 向后兼容入口(main.py 仍可直接调用)
# ─────────────────────────────────────────────────────────────
def dataframe_agent(dashscope_api_key: str, df, query: str) -> Dict[str, Any]:
facade = DataframeAgentFacade(dashscope_api_key)
return facade.analyze(df, query, human_confirmed=True)
界面层直接依赖 DataframeAgentFacade,持久化 facade 实例于 st.session_state,支持人工确认高风险操作、查看审计日志、下载 HTML 报告、质量优化建议四大新功能。
"""main.py — 「数析」智能数据分析台 Streamlit 界面
对接 utils.py 的 DataframeAgentFacade,支持:
- 人工确认高风险操作(Human-in-the-Loop)
- 审计日志侧边栏查看
- HTML 报告一键下载
- 质量优化建议
设计要点:
- 界面层只负责:数据上传、问题收集、结果渲染、交互控制
- 业务逻辑全部委托给 DataframeAgentFacade(单一职责)
- 使用 st.session_state 持久化 facade,保证审计日志与质量建议累积
- 高风险操作默认阻断,必须用户勾选确认才能执行(合规要求)
"""
# ============================
# 目的:把 utils.py 的“产品化能力”用界面方式交付出来
#
# main.py 只负责三件事:
# 1) 读取/上传数据、收集用户自然语言问题
# 2) 调用 DataframeAgentFacade.analyze(...) 获取统一 JSON 结果
# 3) 根据 JSON 字段渲染:answer/table/bar/line/scatter,并展示审计日志
#
# 关键设计:
# - facade 对象放在 st.session_state,保证多轮分析时:审计日志/质量建议能累积
# - 高风险计划默认阻断,必须用户勾选 human_confirmed 才执行(Human-in-the-Loop)
# ============================
import json
import re
import pandas as pd
import streamlit as st
from utils import DataframeAgentFacade
# ─────────────────────────────────────────────
# 工具函数
# ─────────────────────────────────────────────
def render_chart(input_data: dict, chart_type: str):
"""通用图表渲染:bar / line / scatter。
参数:
- input_data: Agent 输出的标准化图表数据(columns/data/x/y/series)
- chart_type: 图表类型('bar'|'line'|'scatter')
处理逻辑:
1) 将 input_data 转为 DataFrame
2) 解析 x/y/series 列映射(处理 LLM 输出不一致问题)
3) 根据图表类型调用 Streamlit 对应图表组件
4) 异常时展示原始数据便于调试
"""
# 约定:input_data 来自 utils.py 的工具输出,结构大致为:
# - bar: {"columns": [x_col, y_col], "data": [[x,y], ...]}
# - line: {"x": "真实x列名", "y": "真实y列名", "columns": [...], "data": [[x,y], ...]}
# - scatter: 同 line
#
# 注意:LLM 可能会输出不一致的字段(例如 columns 里写成 ["x","y"] 占位符),
# 所以这里做了列名解析与兜底渲染,保证页面不“白屏”。
try:
# input_data 是标准化的图表数据结构(由 Agent 输出),包含:
# - columns: 列名
# - data: 二维数组(行)或一维数组(值)
# - x/y/series: 可选,指定 x/y 对应的列名
columns = input_data.get("columns", [])
data = input_data.get("data", [])
x_col = input_data.get("x")
y_col = input_data.get("y")
series = input_data.get("series")
if not (isinstance(data, list) and data):
raise ValueError("数据为空")
if not isinstance(data[0], list):
df_chart = pd.DataFrame({"值": data}, index=columns)
else:
df_chart = pd.DataFrame(data, columns=columns)
# LLM 有时把 columns 写成 ["x","y"] 字面量,但 x/y 字段才是真实列名
# 若 x_col/y_col 不在 df 列名中,尝试把对应位置的列重命名
def resolve_col(col_hint, pos):
"""解析列名:优先使用 col_hint,否则使用位置 pos 的列名。"""
if col_hint and col_hint in df_chart.columns:
return col_hint
if len(df_chart.columns) > pos:
real = df_chart.columns[pos]
if col_hint and col_hint != real:
df_chart.rename(columns={real: col_hint}, inplace=True)
return col_hint if col_hint else real
return None
x_col = resolve_col(x_col, 0)
y_col = resolve_col(y_col, 1)
if series:
series = resolve_col(series, 2)
if chart_type == "bar":
if x_col and y_col and x_col in df_chart.columns:
st.bar_chart(df_chart.set_index(x_col)[y_col])
else:
st.bar_chart(df_chart)
elif chart_type == "line":
if x_col and y_col and x_col in df_chart.columns:
df_sorted = df_chart.sort_values(x_col)
st.line_chart(df_sorted, x=x_col, y=y_col)
else:
st.line_chart(df_chart)
elif chart_type == "scatter":
if x_col and y_col and x_col in df_chart.columns:
st.scatter_chart(df_chart, x=x_col, y=y_col)
else:
st.scatter_chart(df_chart)
except Exception as e:
st.error(f"图表渲染失败: {e}")
with st.expander("原始图表数据"):
st.json(input_data)
def _try_unwrap_json_answer(result: dict) -> dict:
"""若 answer 字段是 JSON 字符串,尝试解析并合并回 result。
背景:
- 部分模型会把“最终 JSON”塞进 answer 字段(字符串形式),导致前端无法直接渲染 table/bar/line。
- 这里做兼容:尽可能把 answer 里的 JSON 解析出来并 merge 回 result。
处理策略:
1) 直接解析 answer 字符串
2) 若被包了一层字符串("{...}"),再解析一次
3) 若被转义(\"{...}\"),先反转义再解析
4) 兜底:用正则抽取 {...} 部分再解析
"""
answer = result.get("answer", "")
if not isinstance(answer, str):
return result
stripped = answer.strip()
# 兼容 3 类常见输出:
# 1) 直接 JSON:{"table":{...}}
# 2) 被包了一层字符串:"{...}"(需要 loads 两次)
# 3) JSON 被转义:\{"table":{...}\}(需要先反转义再 loads)
candidates = []
if stripped:
candidates.append(stripped)
if stripped.startswith('"') and stripped.endswith('"'):
candidates.append(stripped)
if stripped.startswith("{\\\""):
candidates.append(stripped.replace('\\"', '"'))
parsed = None
for cand in candidates:
try:
obj = json.loads(cand)
# 如果外层解析出来还是字符串,再解析一次
if isinstance(obj, str) and obj.strip().startswith("{"):
obj = json.loads(obj)
if isinstance(obj, dict):
parsed = obj
break
except Exception:
continue
if parsed is None:
# 兜底:从 answer 中抽取 {...} 部分再尝试解析
m = re.search(r"\{.*\}", stripped, re.DOTALL)
if not m:
return result
blob = m.group()
for cand in (blob, blob.replace('\\"', '"')):
try:
obj = json.loads(cand)
if isinstance(obj, str) and obj.strip().startswith("{"):
obj = json.loads(obj)
if isinstance(obj, dict):
parsed = obj
break
except Exception:
continue
if parsed is None:
return result
if any(k in parsed for k in ("table", "bar", "line", "scatter", "answer")):
merged = {k: v for k, v in result.items() if k != "answer"}
merged.update(parsed)
return merged
return result
def render_result(result: dict):
"""统一渲染分析结果(answer / table / bar / line / scatter)。
处理流程:
1) 若结果被阻断,展示风险因子并返回
2) 尝试从 answer 字段解析嵌套 JSON(兼容模型输出问题)
3) 展示 trace_id 与执行工具信息
4) 按优先级渲染:answer(仅当无结构化数据)→ table → 图表
重要说明(教学讲解点):
- 后端(utils.py)返回的是“结构化 JSON”,而不是直接返回 DataFrame。
- UI 只做渲染,不做业务计算:这能保证职责清晰、方便测试与复用。
- 若渲染异常,可以通过展开“原始图表数据”、或查看 Trace ID 对应的审计日志来定位。
"""
if result.get("blocked"):
st.error("🚫 高风险操作已被拦截,需人工确认后才能执行。")
st.json({"risk_factors": result.get("risk_factors", [])})
return
# LLM 有时把整个 JSON 塞进 answer 字段,尝试拆解
result = _try_unwrap_json_answer(result)
trace_id = result.get("_trace_id", "N/A")
tools = result.get("_executed_tools", [])
st.caption(f"🔍 Trace ID: `{trace_id}` | 执行工具: {', '.join(tools) or '无'}")
has_visual = any(k in result for k in ("table", "bar", "line", "scatter"))
# 仅当没有结构化数据时才展示文字回答,避免把 JSON 字符串当文字渲染
if "answer" in result and not has_visual:
st.success(result["answer"])
if "table" in result:
t = result["table"]
st.dataframe(pd.DataFrame(t["data"], columns=t["columns"]), use_container_width=True)
for chart_type in ("bar", "line", "scatter"):
if chart_type in result:
st.subheader({"bar": "📊 条形图", "line": "📈 折线图", "scatter": "🔵 散点图"}[chart_type])
render_chart(result[chart_type], chart_type)
# ─────────────────────────────────────────────
# 页面配置
# ─────────────────────────────────────────────
# 配置说明:
# - page_title: 浏览器标签页标题
# - page_icon: 标签页图标(emoji)
# - layout: 'wide' 让内容占满屏幕宽度(适合表格/图表展示)
# ─────────────────────────────────────────────
st.set_page_config(
page_title="「数析」智能数据分析台",
page_icon="📊",
layout="wide",
)
st.title("📊 「数析」智能数据分析台")
st.markdown("基于**通义千问 + 7 大核心能力架构**的智能数据分析平台。")
# ─────────────────────────────────────────────
# 侧边栏
# ─────────────────────────────────────────────
# 侧边栏功能:
# 1) API Key 配置(必填,用于调用通义千问)
# 2) 安全设置:人工确认高风险操作(Human-in-the-Loop)
# 3) 审计日志:展示最近 5 条记录(状态/trace_id/意图)
# 4) 质量建议:基于审计日志生成优化建议
# ─────────────────────────────────────────────
with st.sidebar:
st.header("⚙️ 配置")
api_key = st.text_input("通义千问 API 密钥", type="password",
help="阿里云 DashScope 控制台获取")
st.markdown("[获取 API Key](https://dashscope.console.aliyun.com/apiKey)")
# 人机协同:是否手动确认高风险操作
st.divider()
st.subheader("🛡️ 安全设置")
human_confirmed = st.checkbox(
"我已确认,允许执行高风险操作",
value=False,
help="涉及敏感字段、高危工具或大批量导出时,需勾选此项才能执行。",
)
# 审计日志
st.divider()
st.subheader("📋 审计日志")
if "facade" in st.session_state:
events = st.session_state["facade"].audit.all_events()
st.caption(f"共 {len(events)} 条记录")
for ev in events[-5:]:
color = "🟢" if ev["status"] == "executed" else ("🔴" if ev["status"] == "cancelled" else "🟡")
st.markdown(f"{color} `{ev['trace_id']}` {ev['status']} — {ev['intent'][:20]}")
else:
st.caption("暂无记录")
# 质量优化建议
st.divider()
st.subheader("💡 质量建议")
if st.button("生成优化建议") and "facade" in st.session_state:
for tip in st.session_state["facade"].quality_suggestions():
st.info(tip)
# ─────────────────────────────────────────────
# 主区域:文件上传
# ─────────────────────────────────────────────
# 功能说明:
# - 支持 CSV 文件上传(类型限制)
# - 上传后展示:行数、列数、文件大小、数据预览
# - 数据存入 st.session_state.df,供后续分析使用
# ─────────────────────────────────────────────
st.header("📁 数据上传")
uploaded = st.file_uploader("上传 CSV 数据文件", type="csv")
if uploaded:
try:
st.session_state["df"] = pd.read_csv(uploaded)
df = st.session_state["df"]
c1, c2, c3 = st.columns(3)
c1.metric("行数", df.shape[0])
c2.metric("列数", df.shape[1])
c3.metric("文件大小", f"{uploaded.size / 1024:.1f} KB")
with st.expander("📊 原始数据预览", expanded=True):
st.dataframe(df, height=280, use_container_width=True)
except Exception as e:
st.error(f"文件读取失败: {e}")
# ─────────────────────────────────────────────
# 主区域:智能查询
# ─────────────────────────────────────────────
# 查询流程:
# 1) 收集用户自然语言问题
# 2) 前置校验:API Key、数据文件、查询内容
# 3) 创建/更新 DataframeAgentFacade(存入 session_state)
# 4) 调用 facade.analyze(df, query, human_confirmed)
# 5) 渲染结果(阻断/成功/失败)
# 6) 若成功,提供 HTML 报告下载
# ─────────────────────────────────────────────
st.header("💬 智能查询")
query = st.text_area(
"用自然语言描述分析需求:",
placeholder="例如:近 30 天 GMV 趋势如何? / 哪个渠道转化率最低? / 用散点图展示价格与面积的关系",
height=90,
)
btn = st.button("🚀 开始分析", type="primary")
if btn:
if not api_key:
st.warning("⚠️ 请先输入 API 密钥")
elif "df" not in st.session_state:
st.warning("⚠️ 请先上传数据文件")
elif not query.strip():
st.warning("⚠️ 请输入查询内容")
else:
# 每次点击都重新创建 facade,避免 session_state 缓存旧实例
st.session_state["facade"] = DataframeAgentFacade(api_key)
st.session_state["_api_key"] = api_key
facade: DataframeAgentFacade = st.session_state["facade"] # type: ignore[assignment]
with st.spinner("🤔 AI 正在分析,请稍候…"):
try:
result = facade.analyze(
st.session_state["df"],
query,
human_confirmed=human_confirmed,
)
st.header("📋 分析结果")
render_result(result)
# 报告下载
if not result.get("blocked"):
html_report = facade.export_report(query, result)
st.download_button(
label="⬇️ 下载 HTML 报告",
data=html_report.encode("utf-8"),
file_name="analysis_report.html",
mime="text/html",
)
except ValueError as e:
st.error(f"❌ 分析失败:{e}")
st.info("💡 建议:检查查询是否包含数据中存在的字段名")
except Exception as e:
st.error(f"❌ 系统错误:{e}")DataframeAgentFacade 存入 st.session_state,审计日志跨多次查询累积,支持复盘。human_confirmed,未勾选时高风险计划被阻断并显示风险因子。render_result() 函数按 JSON Schema 自动分发 answer / table / bar / line / scatter,一套代码兼容全类型输出。facade.export_report() 生成含审计日志的完整 HTML,一键下载。facade.quality_suggestions(),基于当前会话审计日志给出可操作建议。# 启动 Streamlit:本地会默认打开浏览器,端口通常是 8501
streamlit run main.py成功启动后,浏览器会自动打开 http://localhost:8501
本项目提供了两个示例数据集,你可以下载后直接上传到应用中进行测试:
本项目的核心在于 Prompt 设计,它决定了 AI 能否准确理解需求并返回正确格式的结果。
面试中,面试官最关注的不是"你做了什么",而是"你解决了什么难题、为什么这样设计"。 以下是本项目中最值得深挖的 6 大亮点,每一个都能在面试中展开讲 3-5 分钟。
以下是根据本项目整理的简历项目经历,按照大厂简历标准格式编写,突出技术深度和业务价值。 可直接复制到简历中,根据个人情况微调即可。
你已掌握「数析」智能数据分析台的开发能力。 接下来,进入阶段 4:「图识」多模态内容识别器, 学习如何将 AI 与图像识别结合,打造多模态内容提取系统。