基于LangChain与Playwright的火车票查询AI Agent开发实战

发布时间:2026/7/6 8:43:53
基于LangChain与Playwright的火车票查询AI Agent开发实战 1. 项目概述一个能听懂人话的火车票查询机器人最近在折腾AI Agent想做个真正能“动手”干活的智能体而不是只会聊天的模型。正好有朋友抱怨每次查火车票都得打开好几个App输入日期、起点终点筛选车次一套流程下来挺费劲。我就琢磨能不能做个Agent你直接跟它说“帮我查一下后天从北京到上海的高铁下午出发的”它就能自己打开网站把结果给你整理好这个想法催生了这个项目一个基于LangChain和Playwright的火车票查询Agent。简单来说这个Agent就是一个能理解你自然语言指令并自动操作浏览器去12306或其他票务网站执行查询任务的智能程序。它把大语言模型LLM的“大脑”和Playwright这个“手”结合了起来。LLM负责理解“人话”比如“后天”、“下午”、“高铁”这些模糊的时间、偏好和车次类型并将其转化为结构化的查询指令Playwright则像一个不知疲倦的、精准的机器人严格按照指令去操控浏览器完成点击、输入、翻页、抓取数据等一系列操作。这个项目特别适合两类朋友一是想入门AI Agent开发尤其是想了解如何让AI“连接”真实世界工具的开发者二是对自动化、RPA机器人流程自动化结合AI感兴趣的朋友。你会发现用LangChain搭建Agent框架再用Playwright赋予其“行动力”思路非常清晰实现起来也很有成就感。接下来我就把自己从零搭建这个Agent的完整过程、踩过的坑以及一些优化心得毫无保留地分享出来。2. 核心架构设计与工具选型2.1 为什么是LangChain Playwright这个组合在开始敲代码之前花点时间想清楚技术选型非常关键。市面上Agent框架不少工具库也很多为什么我最终锁定了LangChain和Playwright呢这背后有几个很实际的考量。首先看LangChain。它本质上是一个用于构建由LLM驱动的应用程序的框架。对于Agent开发来说它最大的价值在于提供了一套成熟的、高层次的抽象。比如它定义了Agent、Tool、AgentExecutor这些核心概念。Tool就是Agent能使用的“工具”比如查询天气、计算器或者我们这里的浏览器操作。Agent是负责决策的“大脑”它根据你的提问和当前状态决定下一步该调用哪个Tool。AgentExecutor则是驱动整个决策-执行循环的“发动机”。用LangChain你不需要从零开始设计Agent的思考逻辑比如ReAct范式它已经帮你封装好了你只需要专注于定义你的工具Tools和给Agent设定清晰的指令Prompt。这对于快速原型开发来说效率提升不是一点半点。然后是Playwright。浏览器自动化工具的选择很多老牌的Selenium轻量级的Pyppeteer。我选择Playwright主要基于三点第一是可靠性。Playwright由微软团队开发对现代Web技术的支持非常好特别是处理单页面应用SPA和动态加载内容时比Selenium稳定得多。它内置了自动等待机制不需要你写一大堆time.sleep和显式等待脚本更健壮。第二是功能强大且易用。它支持Chromium、Firefox、WebKit三大内核录制生成代码的功能非常方便。其API设计也很现代像page.locator()选择器比Selenium的find_element更强大灵活。第三是与Python及异步编程的良好集成。我们的Agent很可能需要处理多个任务或并行查询Playwright对asyncio的原生支持让编写高效异步代码变得很自然。注意有些朋友可能会想到用requests或selenium直接抓取12306接口。但对于12306这类反爬机制复杂、页面结构时常变动的商业网站直接模拟API调用不仅难度高、维护成本大还可能涉及法律风险。使用Playwright模拟真人浏览器操作从公开的网页前端获取信息是更稳妥和可持续的方式。当然这要求我们的代码有良好的容错和选择器更新机制。这个组合的分工就很明确了LangChain作为“指挥官”和“决策中枢”负责理解用户意图并规划行动步骤Playwright作为“特种兵”负责执行具体的、与环境浏览器交互的战术动作。两者通过LangChain的Tool接口无缝连接。2.2 Agent的工作流程与核心组件拆解在我们这个火车票查询Agent里信息是如何流动的呢我画了一个简单的思维流程图来帮助理解这里用文字描述用户输入用户提出一个自然语言查询例如“帮我看看下周五从杭州东站到南京南站晚上六点以后出发的动车票。”LangChain Agent处理指令解析LangChain将用户输入和系统预设的指令Prompt一起发送给LLM例如OpenAI的GPT或本地部署的Qwen。思维规划LLM根据指令理解到需要执行“火车票查询”这个任务。它知道完成这个任务需要一个特定的工具。工具调用LLM生成一个结构化的调用请求指明要使用query_train_tickets这个工具并提取出关键参数departure_city“杭州东站”arrival_city“南京南站”date“下周五”preferred_time“18:00以后”train_type“动车”。Playwright工具执行AgentExecutor接收到LLM的调用指令找到对应的query_train_tickets工具函数并执行。该函数内部启动或复用Playwright浏览器实例导航到12306官网。模拟人工操作在“出发地”输入框填入“杭州东”选择“杭州东站”在“目的地”输入框填入“南京南”选择“南京南站”选择日期点击“查询”按钮。等待页面加载然后使用Playwright定位到车次信息的HTML表格或列表区域。解析HTML提取每一趟车的车次、出发时间、到达时间、历时、座位类型一等座、二等座及价格、余票状态等信息并整理成结构化的数据如列表字典。结果生成与返回Playwright工具函数将结构化的车次信息列表返回给AgentExecutor。AgentExecutor将结果反馈给LLM。LLM对原始数据进行总结、格式化生成一段友好的、易于阅读的自然语言回复例如“已为您查询到下周五X月X日从杭州东到南京南18:00后出发的动车。共找到X个车次其中G7XXX次18:30出发20:10到达二等座票价XX元余票充足D3XXX次...”。最终这段回复呈现给用户。从这个流程可以看出核心组件包括LLM大脑选用一个足够聪明的模型来理解复杂指令和进行推理。项目初期可以用OpenAI API快速验证后期考虑成本或数据隐私可以换用本地模型如Qwen。LangChain Agent框架提供Agent、Tool、AgentExecutor、PromptTemplate等核心类组织整个工作流。Playwright Tool我们将Playwright的浏览器操作封装成一个或多个LangChainTool。这是连接“思考”和“行动”的关键桥梁。解析与数据处理模块负责从Playwright抓取到的HTML中准确提取信息并处理可能的分页、加载更多等情况。3. 开发环境搭建与核心依赖安装3.1 Python环境与包管理我强烈建议使用Python 3.9或更高版本因为LangChain和Playwright的某些特性对新版本Python支持更好。为了避免包冲突使用虚拟环境是必须的。我习惯用conda用venv或pipenv也一样。# 使用 conda 创建环境 conda create -n train_ticket_agent python3.10 conda activate train_ticket_agent # 或者使用 venv python -m venv venv # Windows venv\Scripts\activate # Linux/Mac source venv/bin/activate接下来安装核心依赖。这里列出的是经过我项目验证的版本组合比较稳定。pip install langchain0.1.0 langchain-community0.0.10 # LangChain核心及社区工具 pip install playwright1.40.0 # 浏览器自动化 pip install beautifulsoup44.12.2 # HTML解析备用方案 pip install openai1.3.0 # 如果使用OpenAI API # 或者安装本地模型接口例如 vllm, transformers 等这里以openai为例安装Playwright后还需要安装它需要使用的浏览器内核。Playwright提供了一个很方便的命令来安装playwright install chromium这条命令会下载一个专供Playwright使用的Chromium浏览器它比完整版Chrome更轻量且与Playwright的兼容性有绝对保证。我实测过就用这个自带的Chromium别用自己本地的Chrome能避免很多稀奇古怪的版本问题。3.2 初始化Playwright与浏览器上下文在代码中我们需要以正确的方式启动Playwright。这里有一个关键点对于Agent这种可能被多次调用的服务我们不应该每次执行工具都打开和关闭一个浏览器。那样太慢了。最佳实践是创建一个全局的、可复用的浏览器实例和上下文Context。import asyncio from playwright.async_api import async_playwright class BrowserManager: _instance None _browser None _context None def __new__(cls): if cls._instance is None: cls._instance super(BrowserManager, cls).__new__(cls) return cls._instance async def initialize(self): 初始化浏览器实例只执行一次 if self._browser is None: self.playwright await async_playwright().start() # 使用 headlessFalse 在开发时便于调试生产环境可以设为True self._browser await self.playwright.chromium.launch(headlessFalse, args[--disable-blink-featuresAutomationControlled]) # 创建一个上下文可以设置用户代理、视口大小等模拟更真实的浏览器环境 self._context await self._browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... ) return self._context async def close(self): 关闭浏览器通常在程序退出时调用 if self._browser: await self._browser.close() if self.playwright: await self.playwright.stop() # 全局获取浏览器上下文的函数 async def get_browser_context(): manager BrowserManager() return await manager.initialize()这个BrowserManager用了单例模式确保整个应用生命周期内只有一个浏览器实例。initialize方法会启动浏览器并创建一个上下文Context。上下文比单独的Page标签页更重量级一些它有自己的cookies、缓存和权限设置我们可以利用它来保持登录状态如果需要的话。在工具函数里我们从这个管理器获取上下文然后创建新的Page来执行任务任务完成后关闭Page但保留上下文和浏览器。这能极大提升查询速度。4. 构建Playwright工具让Agent学会“操作浏览器”4.1 将浏览器操作封装为LangChain ToolLangChain的Tool是一个基础类我们需要创建一个继承自它的类并实现_run方法同步或_arun方法异步。由于浏览器操作是I/O密集型的使用异步能更好地利用资源。这里我们实现异步版本。首先定义一个工具函数它包含了用Playwright查询车票的所有逻辑from langchain.tools import BaseTool from pydantic import Field, BaseModel from typing import Type, Optional # 定义工具的输入参数模型 class TrainTicketQueryInput(BaseModel): departure_station: str Field(description出发车站名称如北京南、上海虹桥) arrival_station: str Field(description到达车站名称) date: str Field(description出发日期格式为YYYY-MM-DD或自然语言如明天、下周五需要在函数内部转换) # 可以增加更多可选参数如时间范围、车次类型等 preferred_time_range: Optional[str] Field(defaultNone, description偏好出发时间段如上午、18:00以后) class TrainTicketQueryTool(BaseTool): name query_train_tickets description 用于查询指定日期、出发站和到达站之间的火车票信息。 输入必须包含明确的出发站、到达站和日期。 这个工具会打开12306官网模拟用户操作进行查询并返回车次列表。 args_schema: Type[BaseModel] TrainTicketQueryInput return_direct: bool False # 设为True则工具结果直接返回给用户不经过LLM再加工。我们通常设为False让LLM总结。 async def _arun(self, departure_station: str, arrival_station: str, date: str, preferred_time_range: Optional[str] None) - str: 异步执行工具的核心逻辑 # 1. 获取浏览器上下文并创建新页面 context await get_browser_context() page await context.new_page() try: # 2. 导航到12306官网 await page.goto(https://www.12306.cn.cn/) # 等待页面关键元素加载增加稳定性 await page.wait_for_selector(#fromStationText, statevisible, timeout10000) # 3. 填写查询表单 # 出发站 await page.click(#fromStationText) await page.fill(#fromStationText input, departure_station) # 这里需要处理车站选择列表。12306输入后会下拉提示需要点击选择。 # 一个简单但可能脆弱的办法等待下拉列表出现然后点击第一个选项。 # 更健壮的做法是遍历匹配项找到完全匹配的再点击。 await page.wait_for_selector(#citem_0, timeout5000) await page.click(#citem_0) # 到达站 - 同理 await page.click(#toStationText) await page.fill(#toStationText input, arrival_station) await page.wait_for_selector(#citem_0, timeout5000) await page.click(#citem_0) # 日期选择 - 这是一个难点。12306的日期输入框通常有复杂的日历控件。 # 方案A直接操作输入框的value属性如果允许 await page.eval_on_selector(#train_date, fel el.value {date}) # 方案B更模拟人工的方式点击日期输入框然后点击日历上的特定日期。 # 这里采用方案A因为它简单且对大多数情况有效。但需要确保date格式是YYYY-MM-DD。 # 我们需要一个函数将自然语言日期如“明天”转换为YYYY-MM-DD格式。 formatted_date self._parse_natural_date(date) await page.fill(#train_date, formatted_date) # 4. 点击查询按钮 await page.click(#search_one) # 等待查询结果加载 await page.wait_for_selector(#queryLeftTable, statevisible, timeout15000) # 5. 解析查询结果 # 这里需要仔细分析12306结果页面的HTML结构。 # 通常车次信息在一个id为#queryLeftTable的table里每一行(tr)代表一个车次。 tickets [] rows await page.query_selector_all(#queryLeftTable tr:not(.hidden)) for row in rows: # 提取车次、时间、历时、座位信息等 # 这是一个示例解析逻辑实际HTML结构可能不同需要调整。 train_number_elem await row.query_selector(.number a) train_number await train_number_elem.inner_text() if train_number_elem else N/A start_time_elem await row.query_selector(.start-t) start_time await start_time_elem.inner_text() if start_time_elem else N/A # ... 类似地提取其他信息 ticket_info { 车次: train_number, 出发时间: start_time, # ... 其他字段 } # 可选根据preferred_time_range过滤 if self._filter_by_time(ticket_info, preferred_time_range): tickets.append(ticket_info) # 6. 格式化结果为字符串供LLM处理 if not tickets: result_str f未找到{date}从{departure_station}到{arrival_station}的车次。 else: result_str f共找到{len(tickets)}个车次\n for idx, ticket in enumerate(tickets[:10], 1): # 限制返回前10条避免太长 result_str f{idx}. {ticket[车次]}次{ticket[出发时间]}出发...\n return result_str except Exception as e: # 捕获异常并返回错误信息帮助Agent和用户理解问题 return f查询过程中出现错误{str(e)}。可能是网络问题、网站改版或选择器失效。 finally: # 无论如何关闭当前页面释放资源 await page.close() def _parse_natural_date(self, date_str: str) - str: 将自然语言日期转换为YYYY-MM-DD格式。这是一个简化版。 from datetime import datetime, timedelta today datetime.now().date() if date_str 今天: return today.strftime(%Y-%m-%d) elif date_str 明天: return (today timedelta(days1)).strftime(%Y-%m-%d) elif date_str 后天: return (today timedelta(days2)).strftime(%Y-%m-%d) # 更复杂的解析可以引入第三方库如dateparser # 这里假设输入已经是YYYY-MM-DD格式或简单处理“下周五” # 实际项目中这个函数需要大大加强。 return date_str # 暂时原样返回假设LLM已经处理成标准格式 def _filter_by_time(self, ticket_info: dict, time_range: Optional[str]) - bool: 根据时间偏好过滤车次。这是一个示例。 if not time_range: return True start_time ticket_info.get(出发时间, ) # 实现简单的时间判断逻辑例如“下午”对应12:00以后 # 实际需要更精细的解析 return True这个TrainTicketQueryTool类就是我们的核心工具。name和description非常重要LLM就是根据这些描述来决定什么时候调用这个工具。description要写得清晰、准确说明工具的用途、输入和要求。实操心得description的撰写是门艺术。写得太简单LLM可能不理解或误用写得太复杂又可能干扰LLM的判断。我的经验是采用“功能输入格式输出说明”的结构。例如“用于查询火车票。输入需要包含出发站、到达站和日期格式为YYYY-MM-DD。工具将返回车次列表包括车次号、时间、座位和价格。” 同时在系统Prompt中也要强调Agent应该使用这个工具来查询车票。4.2 处理动态页面与反爬策略12306这类网站的反爬机制比较强我们的Playwright脚本需要模拟得更像真人。随机延迟与操作间隔在关键操作如点击、输入之间加入随机等待时间避免请求过于规律。import random await asyncio.sleep(random.uniform(0.5, 1.5)) # 在点击查询按钮前等待 await page.click(#search_one)使用更稳健的选择器避免使用易变的ID或类名。优先使用># 使用文本定位“查询”按钮假设按钮上有“查询”二字 await page.locator(button:has-text(查询)).click()处理验证码这是最大的挑战。12306在多次非常规查询或特定时段可能会弹出验证码。完全自动化解码非常困难且可能违规。在实践中有几个思路规避策略控制查询频率模拟真人浏览节奏避免触发验证码。半自动处理当检测到验证码出现时例如页面出现特定图片或提示脚本暂停通过某种方式如日志、通知提醒人工干预输入后继续。这适合个人或内部使用。第三方服务慎用使用商业打码平台API但涉及成本和安全风险。本项目定位我们这个项目主要是技术演示和原型验证因此可以在代码中假设一个“理想情况”即不出现验证码或者当出现验证码时工具返回一个友好的错误提示如“查询需要验证码请稍后再试或手动访问网站”。这是最稳妥的做法。错误处理与重试网络波动、元素加载超时是常事。工具函数里要有完善的try...except块对常见错误如超时、元素未找到进行捕获并可能进行有限次数的重试。max_retries 2 for attempt in range(max_retries): try: await page.wait_for_selector(#queryLeftTable, timeout10000) break # 成功则跳出循环 except TimeoutError: if attempt max_retries - 1: return 查询超时可能网络或网站繁忙。 await asyncio.sleep(2) # 等待后重试5. 组装LangChain Agent赋予工具思考与决策能力5.1 设计系统提示词System PromptPrompt是引导LLM行为的关键。对于Agent我们需要在系统提示词中清晰地定义它的角色、能力和行为规范。from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate system_prompt SystemMessagePromptTemplate.from_template( 你是一个专业的火车票查询助手。你的唯一目标就是根据用户的请求使用你拥有的工具来查询火车票信息并以清晰、有条理的方式将结果告知用户。 请遵循以下规则 1. **仔细分析用户请求**提取出“出发站”、“到达站”、“日期”这三个核心信息。如果用户使用了“明天”、“下周五”等相对日期你需要将其转换为具体的日期格式YYYY-MM-DD。如果信息缺失请礼貌地询问用户。 2. **只使用你被赋予的工具**你有一个名为query_train_tickets的工具专门用于查询火车票。不要尝试自己编造车次信息。 3. **规划你的行动**在脑海中规划步骤先确认查询参数然后调用工具最后总结工具返回的结果。 4. **处理工具结果**工具会返回一个车次列表的文本。你需要理解这个文本并将其重新组织成更友好、更易读的格式反馈给用户。例如按时间排序高亮余票充足的车次等。 5. **诚实与边界**如果工具返回错误如网络问题、无车次如实告诉用户。不要猜测或提供不确定的信息。你只能查询车票不能进行购票、改签等操作。 用户的问题可能是模糊的你的任务是澄清并执行。现在开始帮助用户吧。 ) # 人类用户的输入模板 human_prompt HumanMessagePromptTemplate.from_template({input}) # 组合成完整的对话提示 prompt ChatPromptTemplate.from_messages([system_prompt, human_prompt])这个Prompt明确了Agent的职责查询、工具唯一工具、处理流程分析-调用-总结和行为边界不编造、不越权。好的Prompt能显著提升Agent的可靠性和准确性。5.2 创建Agent并集成工具接下来我们需要初始化LLM创建工具列表然后用LangChain提供的create_react_agent或其他Agent类型将它们组装起来。ReActReasoning Acting是一种让LLM在思考生成推理轨迹和行动调用工具之间交替的范式非常适合我们的任务。from langchain.agents import create_react_agent, AgentExecutor from langchain_openai import ChatOpenAI # 示例使用OpenAI # 如果使用本地模型例如通过Ollama或vLLM可以这样 # from langchain_community.llms import Ollama # llm Ollama(modelqwen2:7b) # 1. 初始化LLM llm ChatOpenAI(modelgpt-3.5-turbo, temperature0, openai_api_keyyour-key) # temperature设为0使输出更确定减少随机性。 # 2. 创建工具实例 ticket_tool TrainTicketQueryTool() tools [ticket_tool] # 可以放入更多工具如天气查询、城市代码映射等 # 3. 创建ReAct Agent # LangChain 0.1.x 版本推荐使用 create_react_agent from langchain import hub # 可以从LangChain Hub拉取一个预设的ReAct Prompt也可以使用我们自定义的 # react_prompt hub.pull(hwchase17/react) # 这里我们使用上面自定义的prompt agent create_react_agent(llm, tools, prompt) # 4. 创建Agent执行器 agent_executor AgentExecutor( agentagent, toolstools, verboseTrue, # 设为True可以看到Agent的思考过程调试非常有用 handle_parsing_errorsTrue, # 处理LLM输出格式错误 max_iterations5, # 限制最大迭代次数防止死循环 early_stopping_methodgenerate, # 当Agent认为任务完成时输出Final Answer:提前停止 )AgentExecutor是真正驱动整个循环的组件。verboseTrue时控制台会打印出Agent的“想法”Thought、要执行的行动Action以及观察到的结果Observation这对于调试和理解Agent为何做出某个决策至关重要。5.3 运行与测试Agent现在我们可以用一个简单的脚本来测试我们的Agent了。async def main(): query 我想查一下明天从北京南到上海虹桥的高铁票最好是下午的。 print(f用户提问: {query}) # 注意AgentExecutor的invoke/ainvoke方法是同步/异步的要与工具匹配。 # 我们的工具是异步的所以这里用ainvoke。 try: result await agent_executor.ainvoke({input: query}) print(\n--- Agent回复 ---) print(result[output]) except Exception as e: print(f执行出错: {e}) if __name__ __main__: import asyncio asyncio.run(main())运行这个脚本你应该能在控制台看到类似以下的输出verboseTrue时用户提问: 我想查一下明天从北京南到上海虹桥的高铁票最好是下午的。 Entering new AgentExecutor chain... Thought: 用户想查询明天从北京南到上海虹桥的高铁票偏好下午。我需要提取出发站北京南、到达站上海虹桥、日期明天。我需要将“明天”转换为具体日期。今天是2023-10-26那么明天就是2023-10-27。用户提到了“高铁”这可以作为筛选条件但我的工具描述里没有专门针对车次类型的参数。我可以先查询所有车次然后在总结时筛选出高铁G字头和下午的车次。 Action: query_train_tickets Action Input: {departure_station: 北京南, arrival_station: 上海虹桥, date: 2023-10-27} Observation: 共找到15个车次 1. G101次07:00出发11:23到达... 2. G103次08:05出发12:30到达... ... 10. G117次15:05出发19:30到达... ... Thought: 我已经获得了车次列表。现在我需要从中筛选出高铁G字头和下午假设12:00以后出发的车次并以友好的方式呈现给用户。 Final Answer: 已为您查询到明天2023-10-27从北京南站到上海虹桥站的车次。根据您对“高铁”和“下午出发”的偏好筛选结果如下 - G115次14:00出发18:20到达二等座票价553元余票充足。 - G117次15:05出发19:30到达二等座票价553元余票充足。 - G119次16:10出发20:35到达二等座票价553元余票较少。 以上车次均为高铁G字头且出发时间在下午。您可以选择合适的车次。 Finished chain. --- Agent回复 --- 已为您查询到明天2023-10-27从北京南站到上海虹桥站的车次。根据您对“高铁”和“下午出发”的偏好筛选结果如下 - G115次14:00出发18:20到达二等座票价553元余票充足。 - G117次15:05出发19:30到达二等座票价553元余票充足。 - G119次16:10出发20:35到达二等座票价553元余票较少。 以上车次均为高铁G字头且出发时间在下午。您可以选择合适的车次。看Agent成功地理解了模糊需求“明天”、“下午”、“高铁”将“明天”转换成了具体日期调用了正确的工具并对工具返回的原始数据进行了二次加工和筛选给出了一个非常人性化的回答。这就是AI Agent的魅力所在。6. 高级优化与实战经验分享6.1 提升Agent的推理与规划能力基础的ReAct Agent已经能工作但面对复杂查询时可能显得笨拙。比如用户问“我和朋友下周末想去杭州玩从北京出发看看周五晚上走周日晚上回来的票哪个组合最便宜” 这涉及多日期、多行程的查询和比价。这时就需要更强大的规划能力。方案一使用LangChain的Plan-and-Execute模式或LangGraphLangChain提供了更高级的Plan-and-Execute架构它使用一个“规划者”LLM先制定一个多步骤计划然后由一个“执行者”LLM或Agent按步骤调用工具。这适合复杂任务分解。而LangGraph是另一个强大的库它允许你用图Graph的方式来定义Agent的工作流节点是状态或工具调用边是控制流。对于“查询去程票-查询返程票-比较价格”这样的多步骤、有条件分支的任务用LangGraph建模会更清晰、可控。# 这是一个使用LangGraph的简化概念示例并非完整代码 from langgraph.graph import StateGraph, END from typing import TypedDict class AgentState(TypedDict): user_query: str parsed_query: dict outbound_tickets: list return_tickets: list final_answer: str def plan_step(state: AgentState): 规划节点分析查询决定步骤 # 调用一个LLM来分析用户查询输出一个计划例如 # {steps: [query_outbound, query_return, compare_and_summarize]} ... return {plan: plan} def query_outbound_step(state: AgentState): 执行节点1查询去程票 # 调用我们的火车票查询工具 ... return {outbound_tickets: tickets} def query_return_step(state: AgentState): 执行节点2查询返程票 ... return {return_tickets: tickets} def compare_and_summarize_step(state: AgentState): 执行节点3比较并总结 # 根据去程和返程票计算总价找出最便宜组合 ... return {final_answer: answer} # 构建图 workflow StateGraph(AgentState) workflow.add_node(plan, plan_step) workflow.add_node(query_outbound, query_outbound_step) workflow.add_node(query_return, query_return_step) workflow.add_node(summarize, compare_and_summarize_step) # 定义边控制流 workflow.set_entry_point(plan) workflow.add_edge(plan, query_outbound) workflow.add_edge(query_outbound, query_return) workflow.add_edge(query_return, summarize) workflow.add_edge(summarize, END) # 编译并运行 app workflow.compile() result app.invoke({user_query: 查询北京到杭州下周五晚去周日晚上回的最便宜组合})方案二设计更智能的Tool与其让Agent自己去做复杂的过滤和比较不如把一部分逻辑下放到Tool里。我们可以设计一个更强大的query_tickets_with_filters工具它直接接受时间范围、车次类型、排序方式如按价格、按时间等参数在工具内部完成数据获取和初步处理返回更结构化的结果如JSON减轻Agent的认知负担。6.2 处理复杂、模糊的自然语言输入用户不会总是说“查询2024-05-20从北京到上海的车票”。他们更可能说“大后天下午去南京的票还有吗”或“五一从广州去武汉有什么推荐的车次”。日期解析这是最常见的难点。除了自己写规则如昨天、今天、明天、下周X强烈推荐使用dateparser或pendulum库。它们能很好地处理“大后天”、“下个月五号”、“劳动节”这样的表达。import dateparser date_str 下周五下午 parsed_date dateparser.parse(date_str, languages[zh]) if parsed_date: formatted_date parsed_date.strftime(%Y-%m-%d)地点标准化用户可能输入“北京”、“北京市”、“北京站”、“北京南”。我们需要一个地点映射表将模糊输入映射到12306官网认可的标准车站名。可以维护一个本地JSON文件或查询外部API。意图识别与槽位填充对于更复杂的查询如“帮我找找时间最短的”或“不要红眼航班这里指夜间车”这属于更高级的意图识别。可以在Agent之前加一个专门的“语义解析”步骤使用一个LLM调用将用户输入解析成结构化的查询对象包含出发地、目的地、日期、偏好等字段再交给Agent执行。这实际上是把一部分Agent的“思考”工作前置了。6.3 性能、稳定性与错误处理异步并发如果Agent需要并行查询多个日期或路线可以使用asyncio.gather来并发执行多个Playwright工具调用但要小心网站的反爬机制控制并发数。超时与重试为Playwright的wait_for_selector、goto等操作设置合理的超时时间。对于整个工具调用也可以设置总超时。实现重试逻辑但要有退避策略如指数退避避免对网站造成压力。结果缓存对于相同的查询出发地、目的地、日期完全相同可以考虑在短时间内缓存结果避免重复请求提升响应速度并减少网站负载。健康检查与监控定期运行一个简单的查询任务检查工具是否正常工作。记录日志包括查询参数、执行时间、是否成功、错误信息等便于问题排查。选择器维护网站前端改版是最大的风险。将CSS选择器、XPath等定位信息集中管理在配置文件中而不是硬编码在工具函数里。一旦网站改版只需更新配置文件。甚至可以考虑写一个简单的“选择器测试脚本”定期运行以确保定位器依然有效。7. 项目总结与避坑指南这个项目从构思到实现走通了一个完整的AI Agent链路从自然语言理解到任务规划再到通过工具与真实世界网页交互最后总结结果。它麻雀虽小五脏俱全涵盖了Agent开发的核心概念。我踩过的一些坑和总结的经验Prompt工程是关键中的关键Agent的“智商”和“性格”很大程度上由系统Prompt决定。花时间精心打磨Prompt明确指令、约束和输出格式比盲目调整模型参数更有效。让Agent“少说废话多干实事”。工具描述要精准Tool的name和description是LLM决定是否调用、如何调用的唯一依据。描述要清晰说明输入格式、输出内容以及工具的局限性。例如明确写“日期格式必须为YYYY-MM-DD”。Playwright的稳定性虽然Playwright很强大但在生产环境中网站结构变化、网络抖动、验证码都是挑战。不要指望写一次脚本就能永远运行。必须建立监控和定期维护机制。对于关键业务考虑使用更稳定的官方API如果有的话或与供应商合作。错误处理要友好工具函数内部要捕获所有可能异常并返回对LLM和最终用户都有意义的错误信息。不要抛出未处理的异常这会导致整个Agent链条崩溃。返回如“查询失败无法找到出发地输入框可能网站页面已更新。”这样的信息。本地模型 vs. 云API初期验证用GPT-3.5/4非常快。但如果考虑成本、数据隐私或长期使用部署本地模型如Qwen、ChatGLM是必然选择。这需要解决本地模型的推理速度、知识截止日期以及对ReAct格式指令的遵循能力等问题。可能需要对Prompt进行针对性的调整和微调。这个项目的边界务必清楚这是一个查询演示Agent不是购票机器人。自动购票涉及支付、个人信息复杂度、法律风险和道德风险都极高。本项目严格限定在公开信息查询的范畴。后续可以扩展的方向多工具集成除了查火车票还可以集成天气查询判断出行天气、地图工具计算从家到车站的时间、票价对比聚合多个票务平台等打造一个真正的“出行规划助手”。记忆与多轮对话让Agent能记住上下文。比如用户先问“北京到上海的高铁”接着问“那最晚的一班呢”Agent应该能理解“那”指的是上一轮查询的路线。这需要引入对话记忆管理。Web UI或聊天机器人集成将Agent封装成API然后接入微信机器人、Discord Bot或一个简单的网页前端让更多人能方便使用。引入RAG如果想让Agent回答更广泛的问题比如“乘坐高铁可以带酒吗”可以为其构建一个关于铁路旅行规则的知识库通过检索增强生成RAG来提供准确答案。构建一个实用的AI Agent就像教一个聪明的孩子学会使用各种工具。LangChain提供了教学大纲和课堂纪律框架和范式Playwright给了它灵巧的双手浏览器自动化而我们的任务就是设计好每节课的内容Tool并不断地纠正和引导Prompt Engineering。这个过程充满挑战但当你看到它真正理解你的意图并完成任务时那种成就感是无与伦比的。希望这篇超详细的实战记录能帮你少走弯路更快地构建出属于自己的那个“智能体”。