多轮对话式新增维修记录实现方案(Agent 一问一答收集信息)
这个方案的核心是:Agent 通过多轮对话逐步收集必填信息,全部确认后自动调用固化接口写入数据库。全程自然语言一问一答,用户不用管字段格式,Agent 会主动引导、校验、确认,最后完成提交。
核心安全原则:写操作绝对不走 Text-to-SQL 自由生成,全部通过固化的参数化工具执行,从根源避免 SQL 注入和误操作。
一、整体交互流程
用户:我要加一条维修记录
↓
Agent 识别意图 → 检查已收集信息
↓
缺什么问什么(每次只问一个,一问一答)
↓
问:请问车辆自编号是多少?
用户:11902
问:故障类型是什么呢?
用户:电机故障
... 逐步收集 ...
↓
信息收集完整 → Agent 复述确认
↓
用户确认无误 → 调用固化写入工具 → 写入数据库 → 返回结果
约定字段(可按你的业务调整)
| 字段 | 必填 | 说明 |
|---|---|---|
| bus_no | 是 | 车辆自编号 |
| fault_type | 是 | 故障类型(电机系统/底盘系统/电气系统/车身系统) |
| repair_content | 是 | 维修内容描述 |
| repair_time | 否 | 维修时间,不传默认当前时间 |
| handler | 否 | 处理人 |
| remark | 否 | 备注 |
二、核心实现思路
- 新增固化写入工具:专门负责写入数据库,参数化查询,绝对安全
- Agent 多轮信息收集:通过对话历史记住已填字段,缺什么问什么
- 确认机制:提交前复述信息,用户确认后再写入,防止误操作
- 字段校验:Agent 自动校验格式,不对就提示重填
三、完整代码实现(在之前版本上扩展)
下面标注了所有新增/修改的部分,直接替换对应位置即可。
python
from dotenv import load_dotenv
import os
import ast
import uuid
from contextlib import asynccontextmanager
from typing import List, Dict
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
from langchain_community.utilities import SQLDatabase
from langchain_community.agent_toolkits import create_sql_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain_core.prompts import ChatPromptTemplate
# ===================== 配置 =====================
TEMP_DIR = "temp_excel_files"
EXPIRE_HOURS = 1
os.makedirs(TEMP_DIR, exist_ok=True)
load_dotenv()
# 全局变量
sql_agent = None
agent_executor = None
# ===================== 1. 业务查询函数(只读) =====================
def get_fleet_vehicles(company_name: str, fleet_name: str) -> List[Dict]:
return [
{"车辆自编号": "11901", "车牌号": "沪A12345", "车型": "宇通ZK6125", "状态": "运营中"},
{"车辆自编号": "11902", "车牌号": "沪A12346", "车型": "宇通ZK6125", "状态": "维修中"},
{"车辆自编号": "11903", "车牌号": "沪A12347", "车型": "申龙SLK6119", "状态": "运营中"},
]
def get_vehicle_repair_history(bus_no: str) -> List[Dict]:
return [
{"工单号": "R202609001", "故障类型": "电机故障", "维修时间": "2026-09-01", "维修内容": "更换轴承"},
{"工单号": "R202608015", "故障类型": "电路故障", "维修时间": "2026-08-15", "维修内容": "更换线束"},
]
def get_fleet_monthly_stats(fleet_name: str, month: str) -> Dict:
return {
"车队名称": fleet_name,
"统计月份": month,
"总工单数量": 28,
"已完成工单": 25,
"故障类型TOP3": "电机系统、底盘系统、电气系统",
"平均维修工时(小时)": 4.2
}
# ===================== 【新增】2. 写入数据库工具(固化写操作) =====================
# 注意:这里单独使用有写入权限的数据库连接,和只读查询分开
# 生产环境建议:使用独立的数据库账号,仅授予 repair_order 表的 INSERT 权限
def _get_write_db():
"""获取写权限的数据库连接(单独配置,权限最小化)"""
# 建议在 .env 里单独配置 WRITE_MYSQL_URI
write_uri = os.getenv("WRITE_MYSQL_URI", os.getenv("MYSQL_URI"))
return SQLDatabase.from_uri(
write_uri,
include_tables=["repair_order"],
view_support=False
)
@tool
def add_repair_record(
bus_no: str,
fault_type: str,
repair_content: str,
repair_time: str = None,
handler: str = None,
remark: str = None
) -> str:
"""
新增一条车辆维修记录到数据库。
参数:
- bus_no: 车辆自编号,必填,比如"11901"
- fault_type: 故障类型,必填,可选值:电机系统、底盘系统、电气系统、车身系统
- repair_content: 维修内容描述,必填
- repair_time: 维修时间,可选,格式YYYY-MM-DD HH:MM:SS,不传默认当前时间
- handler: 处理人,可选
- remark: 备注,可选
【使用规则】
1. 必须所有必填参数都收集完整才能调用
2. 调用前必须向用户复述信息并确认
3. 故障类型必须是可选值范围内的
"""
try:
db = _get_write_db()
# 默认当前时间
if not repair_time:
repair_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 参数化插入,绝对安全
sql = """
INSERT INTO repair_order (vehicle_id, fault_type, repair_content, order_time, handler, remark, status)
SELECT vehicle_id, :fault_type, :repair_content, :order_time, :handler, :remark, 2
FROM bus_vehicle
WHERE bus_no = :bus_no
"""
params = {
"bus_no": bus_no,
"fault_type": fault_type,
"repair_content": repair_content,
"order_time": repair_time,
"handler": handler,
"remark": remark
}
# 执行写入
db.run(sql, parameters=params)
return f"✅ 维修记录添加成功!\n车辆:{bus_no}\n故障类型:{fault_type}\n维修时间:{repair_time}"
except Exception as e:
return f"❌ 添加失败:{str(e)}\n请检查车辆编号是否正确,或联系管理员。"
# ===================== 3. 业务查询工具封装 =====================
@tool
def query_fleet_vehicles(company_name: str, fleet_name: str) -> str:
"""
查询指定分公司和车队的车辆清单,返回车辆数据列表。
参数:
- company_name: 分公司名称,比如"第一运营分公司"、"一公司"
- fleet_name: 车队名称,比如"119车队"
当用户查询某个车队的车辆清单、车辆列表时,优先使用此工具。
"""
if company_name in ["一公司", "第一分公司"]:
company_name = "第一运营分公司"
result = get_fleet_vehicles(company_name, fleet_name)
return str(result)
@tool
def query_vehicle_repair_history(bus_no: str) -> str:
"""
查询指定车辆的历史维修记录,返回维修记录列表。
参数:
- bus_no: 车辆自编号,比如"11901"
当用户查询某辆车的维修记录、维修历史、修过什么时,优先使用此工具。
"""
result = get_vehicle_repair_history(bus_no)
return str(result)
@tool
def query_fleet_monthly_stats(fleet_name: str, month: str) -> str:
"""
查询指定车队某个月的维修统计数据。
参数:
- fleet_name: 车队名称,比如"119车队"
- month: 月份,格式为YYYY-MM,比如"2026-09"
当用户查询车队月度统计、维修数据、工单数量时,优先使用此工具。
"""
result = get_fleet_monthly_stats(fleet_name, month)
return str(result)
BUSINESS_TOOLS = [query_fleet_vehicles, query_vehicle_repair_history, query_fleet_monthly_stats]
# ===================== 4. Excel导出工具 =====================
@tool
def export_to_excel(data: str, file_title: str = "数据导出") -> str:
"""
将查询到的数据导出为Excel文件,返回下载链接。
参数:
- data: 要导出的数据,必须是Python列表字典格式的字符串
- file_title: 导出文件的名称
当用户要求导出数据、下载Excel、生成报表时,使用此工具。
使用步骤:先调用查询工具获取数据,再调用此工具导出。
"""
_clean_expired_files()
try:
data_list = ast.literal_eval(data.strip())
except Exception as e:
return f"数据解析失败,无法导出:{str(e)}"
if not isinstance(data_list, list) or len(data_list) == 0:
return "没有有效数据可导出"
if not isinstance(data_list[0], dict):
return "数据格式错误,必须是字典列表格式"
try:
import openpyxl
file_id = str(uuid.uuid4())
file_name = f"{file_title}_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx"
file_path = os.path.join(TEMP_DIR, f"{file_id}.xlsx")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = file_title[:31] if len(file_title) <= 31 else "数据"
headers = list(data_list[0].keys())
ws.append(headers)
for row in data_list:
ws.append([str(row.get(h, "")) for h in headers])
for col in ws.columns:
max_len = 0
col_letter = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_len:
max_len = len(str(cell.value))
except:
pass
ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 50)
wb.save(file_path)
download_url = f"[http://localhost:8000/api/download/{file_id}?filename={file_name}](http://localhost:8000/api/download/{file_id}?filename={file_name})"
return f"Excel文件已生成,点击下载:{download_url}\n文件有效期1小时,请及时保存。"
except Exception as e:
return f"生成Excel失败:{str(e)}"
def _clean_expired_files():
try:
cutoff = datetime.now() - timedelta(hours=EXPIRE_HOURS)
for filename in os.listdir(TEMP_DIR):
file_path = os.path.join(TEMP_DIR, filename)
if os.path.isfile(file_path):
mtime = datetime.fromtimestamp(os.path.getmtime(file_path))
if mtime < cutoff:
os.remove(file_path)
except:
pass
# ===================== 5. 服务启动初始化 =====================
@asynccontextmanager
async def lifespan(app: FastAPI):
global sql_agent, agent_executor
print("正在初始化数据库连接...")
db = SQLDatabase.from_uri(
os.getenv("MYSQL_URI"),
include_tables=["bus_vehicle", "bus_fleet", "repair_order"],
sample_rows_in_table_info=2,
view_support=False
)
print("正在初始化大模型...")
llm = ChatOpenAI(
model=os.getenv("MODEL_NAME"),
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
temperature=0.3 # 对话场景稍微调高一点,更自然
)
# SQL查询工具(只读)
print("正在创建SQL查询Agent...")
sql_agent = create_sql_agent(
llm=llm,
db=db,
agent_type="openai-tools",
verbose=False,
max_iterations=10,
handle_parsing_errors=True
)
@tool
def sql_database_query(query: str) -> str:
"""
通过自然语言灵活查询公交维修数据库,支持筛选、统计、关联查询。
当没有现成的业务工具可以满足需求时,使用此工具。
参数:
- query: 用户的自然语言查询问题
注意:只能查询,不能修改、删除、新增数据。
"""
result = sql_agent.invoke({"input": query})
return result["output"]
# 合并所有工具:业务查询 + SQL查询 + Excel导出 + 新增维修记录
all_tools = BUSINESS_TOOLS + [sql_database_query, export_to_excel, add_repair_record]
# 创建总调度Agent
print("正在创建总调度Agent...")
system_prompt = """
你是公交机务智能助手,支持查询数据、导出Excel、新增维修记录。
【核心规则】
1. 查询类优先用业务工具,满足不了再用 sql_database_query
2. 写操作只能用 add_repair_record 工具,绝对不能用SQL生成写操作
3. 新增维修记录必须严格按流程来,禁止跳过确认直接提交
【新增维修记录流程】
用户说要加维修记录、登记维修、新增工单时,按以下步骤执行:
1. 先检查对话历史里已经有哪些字段信息
2. 每次只问一个缺失的必填字段,一问一答,不要一次问多个
3. 必填字段:车辆自编号(bus_no)、故障类型(fault_type)、维修内容(repair_content)
可选字段:维修时间、处理人、备注
4. 故障类型只能是:电机系统、底盘系统、电气系统、车身系统,不对就提示用户选择
5. 所有必填字段收集完后,向用户复述一遍全部信息,确认无误后再调用 add_repair_record
6. 如果用户说"确认"、"没问题"、"提交",再调用工具写入
7. 写入成功后告知用户结果,失败就说明原因
【导出Excel规则】
先查询数据,再用 export_to_excel 导出,返回下载链接
【回答要求】
1. 自然口语化,不要太生硬
2. 不要暴露工具、SQL、接口等技术术语
3. 不确定就问用户,不要猜测
"""
prompt = ChatPromptTemplate.from_messages([
("system", system_prompt),
("user", "{input}"),
("agent_scratchpad", "{agent_scratchpad}"),
])
agent = create_openai_tools_agent(llm, all_tools, prompt)
agent_executor = AgentExecutor(
agent=agent,
tools=all_tools,
verbose=True,
max_iterations=15, # 多轮对话需要更多迭代
handle_parsing_errors=True
)
print("服务启动完成!")
yield
print("服务已关闭")
# ===================== 6. FastAPI应用 =====================
app = FastAPI(
title="公交机务智能查询系统",
description="支持查询、导出、新增维修记录的智能对话系统",
version="2.2.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ===================== 7. 对外接口 =====================
class QueryRequest(BaseModel):
session_id: str
question: str
class QueryResponse(BaseModel):
code: int = 200
message: str = "success"
data: str = ""
# 会话存储(多轮对话必须)
session_store = {}
MAX_HISTORY_ROUNDS = 20
def get_session_history(session_id: str) -> list:
if session_id not in session_store:
session_store[session_id] = []
return session_store[session_id]
def append_history(session_id: str, user_question: str, ai_answer: str):
history = get_session_history(session_id)
history.append(f"用户:{user_question}")
history.append(f"助手:{ai_answer}")
if len(history) > MAX_HISTORY_ROUNDS * 2:
session_store[session_id] = history[-MAX_HISTORY_ROUNDS * 2:]
@app.get("/health", summary="健康检查")
async def health_check():
return {"code": 200, "message": "服务运行正常"}
@app.post("/api/chat", summary="智能对话接口(支持多轮)", response_model=QueryResponse)
async def chat(request: QueryRequest):
if not agent_executor:
raise HTTPException(status_code=500, detail="服务未初始化完成")
session_id = request.session_id.strip()
question = request.question.strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id不能为空")
if not question:
raise HTTPException(status_code=400, detail="问题不能为空")
try:
# 拼接历史对话
history = get_session_history(session_id)
history_text = "\n".join(history) if history else ""
full_input = f"""
【对话历史】
{history_text}
【当前用户说】
{question}
"""
# 调用Agent
result = agent_executor.invoke({"input": full_input})
answer = result["output"]
# 保存历史
append_history(session_id, question, answer)
return QueryResponse(
code=200,
message="success",
data=answer
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"处理失败:{str(e)}")
@app.get("/api/download/{file_id}", summary="下载Excel文件")
async def download_excel(file_id: str, filename: str = "数据导出.xlsx"):
file_path = os.path.join(TEMP_DIR, f"{file_id}.xlsx")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="文件不存在或已过期")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
四、对话效果演示
用户:我要加一条维修记录
助手:好的,请告诉我车辆自编号是多少?
用户:11902
助手:收到,请问故障类型是哪个呢?可选:电机系统、底盘系统、电气系统、车身系统
用户:电机系统
助手:好的,请描述一下维修内容?
用户:更换驱动电机轴承,调整间隙
助手:好的,还有补充信息吗?比如维修时间、处理人、备注,没有的话可以不说。
用户:处理人张三
助手:我跟你确认一下信息:
- 车辆自编号:11902
- 故障类型:电机系统
- 维修内容:更换驱动电机轴承,调整间隙
- 处理人:张三
- 维修时间:默认当前时间
确认无误的话请说"提交",我帮你登记。
用户:提交
助手:✅ 维修记录添加成功!
车辆:11902
故障类型:电机系统
维修时间:2026-10-01 15:30:22
五、关键说明与安全建议
1. 数据库权限分离(非常重要)
- 查询用只读账号:只给 SELECT 权限,给 Text-to-SQL 和查询工具用
- 写入用单独账号:只给 repair_order 表的 INSERT 权限,仅给 add_repair_record 工具用
- 禁止用高权限账号统一处理,最小权限原则
2. 可优化的点
- 字段校验增强:可以在工具里加车辆编号存在性校验、故障类型枚举校验
- 工单编号生成:可以按规则自动生成工单号,不用用户填
- 操作日志:写入时记录操作人、时间、IP,便于审计
- 状态流转:新增默认是"维修中"状态,后续可以加完工接口
3. 前端对接
- 前端用同一个
session_id保持对话 - 可以做成聊天窗口样式,体验更好
- 提交确认环节可以做成按钮,不用用户打字
4. 环境配置
在 .env 里增加写权限的数据库连接:
env
WRITE_MYSQL_URI=mysql+pymysql://写入账号:密码@地址:3306/数据库名
六、测试方法
启动服务后访问 [http://localhost:8000/docs](http://localhost:8000/docs),找到 /api/chat 接口:
- 第一次传:
session_id: "test001", question: "我要加一条维修记录" - 第二次传:同一个
session_id,question: "11902" - 逐步回复,直到最后提交
开启 verbose=True 可以在控制台看到 Agent 每一步的决策:收集了什么字段、缺什么、什么时候调用工具。