当前位置: 首页 > news >正文

Tool-using LLM构建通勤规划Agent:语义层与四层架构实践

1. 项目概述:当通勤变成一场人机协作的精密演出

你有没有过这样的经历?早上七点,站在 Baker Street 地铁站口,手机里打开 TfL 官网,手指在“Journey Planner”输入框里反复删改:“Home to Tate Modern, 8:15am, avoid stairs”。敲下回车,页面跳转,三页密密麻麻的换乘方案、步行分钟数、实时延误提示扑面而来。你盯着屏幕,大脑飞速运转:哪条线最不挤?那个“via Waterloo”是不是意味着要横跨整个车站?最后,你还是掏出纸笔,把关键信息抄下来——这哪是规划通勤,分明是在解一道带实时变量的多目标优化题。

这就是 London Commute Agent 这个项目想真正解决的问题。它不是另一个花哨的聊天机器人,而是一套嵌入真实业务流程的语义翻译器。核心关键词就三个:Tool-using LLM(工具调用型大模型)Transport for London (TfL) APISemantic Layer(语义层)。它要干的事,是让“我想八点十五从家出发去泰特现代美术馆,最好别坐地铁,能骑车就骑车”这样一句充满模糊性、文化暗示和隐含约束的人类语言,被精准地拆解、翻译、调度,最终生成一张带颜色编码路线的 Folium 地图、一份可导入日历的 ICS 文件,以及一段连保洁阿姨都能听懂的语音导航稿。

我试过很多种方案。最早用纯 Prompt Engineering,把 TfL API 文档全文喂给 Claude,让它“理解”参数含义。结果呢?它能把fromPlacetoPlace解析成伦敦邮编,但一旦遇到“home”这种词,就直接返回“无法定位家庭地址”。后来我加了地理编码服务,又发现 API 返回的 JSON 结构太深,LLM 在嵌套五层的对象里找journeyResults[0].legs[2].mode.name时,十次有七次会漏掉legs这个键。直到我把整个流程切成四层:Router(总控)、Planner(路线计算)、Disambiguator(地点消歧)、Output Artifact(成果输出),每一层只负责一个明确的、边界清晰的子任务,并且强制所有工具调用都走结构化 Schema,问题才真正收敛。这个项目的价值,不在于它多酷炫,而在于它提供了一套可复用的“人机协作协议”——告诉你当 LLM 不再是万能胶水,而是需要被当作一个有脾气、有局限、必须被精心设计接口的“新同事”来对待时,工程上该怎么落地。

2. 整体架构设计:为什么必须分层?为什么不能一股脑全塞给大模型?

2.1 四层代理架构的底层逻辑:对抗 LLM 的“认知过载”

很多人看到“Agent”这个词,第一反应是“哦,就是让大模型自己思考、自己调用工具”。但实际踩坑后你会发现,这种想法非常危险。LLM 的本质是一个概率预测引擎,它的强项是模式匹配和文本生成,弱项是精确的状态跟踪、长链路的逻辑推理,以及对复杂嵌套数据结构的稳定解析。London Commute Agent 的四层设计(Router → Planner → Disambiguator → Output Artifact),根本目的不是为了炫技,而是为了主动给 LLM 划定认知边界,把它的“思考”压缩在一个可控的、低熵的决策空间里

我们来拆解一下这个架构的“反直觉”之处。传统思路会觉得:Router 层应该最“聪明”,它要理解用户意图、决定调用哪个工具、汇总所有结果。但在这个项目里,Router 层反而被刻意设计得“很笨”。它的核心职责只有两个:1)识别用户请求中是否包含明确的地理实体(如 “Baker Street”, “Tate Modern”);2)如果识别失败,则触发 Disambiguator 工具,把模糊词(如 “home”, “work”)转换成标准坐标。它绝不去碰 TfL API 返回的任何行程细节,也绝不去渲染地图或写日历。这些重活,全部交给下游的专用 Agent。

为什么?因为实测下来,当 Router 的 prompt 里混入了 TfL API 的完整响应结构(比如journeyResults[].legs[].departurePoint.lat这样的字段路径),Claude 3 Opus 的 token 消耗会飙升 40%,而且错误率从 5% 直接跳到 22%。它开始在departurePointarrivalPoint之间随机混淆,或者把duration(秒)当成分钟来用。这不是模型能力问题,而是人类工程师没给它一个“舒适区”。就像你不会让一个刚入职的实习生同时负责财务审计、法务合同和产品设计,你得先让他从整理会议纪要开始。

所以,这个架构的每一层,都是一个“认知减压阀”:

  • Router 层:只处理“是什么”(What),不处理“怎么做”(How)。它像一个前台接待,只问“您要去哪儿?几点出发?有什么特殊要求?”,然后把问题分发给对应部门。
  • Planner 层:只处理“怎么算”(How)。它拿到 Router 分发的标准化坐标和时间,调用 TfL Journey API,把原始 JSON 响应封装成JourneyPlan两个自定义数据类。它不关心地图长什么样,也不管用户想不想看日历。
  • Disambiguator 层:只处理“到底指哪个”(Which One)。当 TfL API 返回一堆叫 “Baker Street”的站点时,它负责根据上下文(比如用户说“home”,而历史记录显示他常住 NW1)选出最可能的那个。它不参与任何路线计算。
  • Output Artifact 层:只处理“怎么呈现”(How to Show)。它接收 Planner 计算好的Journey对象,调用 Folium 绘制地图,调用ics库生成日历,调用 Jinja2 模板生成自然语言描述。它对 TfL API 一无所知。

这种设计带来的直接好处是:调试成本断崖式下降。如果地图画错了,你只需要检查 Output Artifact 层的draw_map_for_plan函数;如果路线规划结果为空,你直接去看 Planner 层的JourneyPlannerSearchPayloadProcessor是否正确过滤了无效响应;如果用户说“home”没被识别,那问题 100% 出在 Disambiguator 层的地理编码逻辑里。没有模糊地带,没有“可能是 A 也可能是 B”的扯皮。

2.2 语义层(Semantic Layer):不是魔法,而是精心设计的“翻译词典”

“语义层”这个词听起来很高大上,但在工程实践中,它就是一本由开发者亲手编写的、给 LLM 看的“翻译词典”。它的核心作用,是把人类语言的模糊性,映射到机器世界的确定性上。这个映射过程,绝不是靠 LLM 自己“悟”出来的,而是靠三样东西硬生生搭起来的:工具描述(Tool Descriptions)、输入 Schema(Input Schema)、以及严格的类型约束(Type Constraints)

我们以compute_journey_plans这个工具为例。它的 JSON Schema 定义里,input_prompt字段的description是这样写的:“A natural language description of the journey request, including origin, destination, time, and any preferences (e.g., 'avoid stairs', 'prefer cycling'). Do not include coordinates; only use place names or common references.” 这句话不是随便写的。它明确告诉 LLM:1)你只能处理自然语言;2)你不能自己去查坐标(那是 Disambiguator 的事);3)你只关注四个要素(起点、终点、时间、偏好)。这就把 LLM 的“自由发挥空间”锁死了。

更关键的是input_schema里的properties。它规定input_prompt必须是string类型,input_structured必须是object类型,且内部必须包含journey_indexplan_index两个整数。这意味着,当 Router Agent 调用这个工具时,它传进去的数据,必须是 Python 字典{"input_prompt": "from home to Tate Modern", "input_structured": {"journey_index": 0, "plan_index": 2}}。如果传进去的是一个字符串"{'input_prompt': ...}",整个调用就会失败。这种强制性的类型检查,是防止 LLM “胡言乱语”的最后一道保险。

我曾经犯过一个典型错误:为了让 LLM 更“灵活”,我在input_prompt的 description 里加了一句 “You may also include approximate coordinates if you know them.” 结果呢?LLM 开始在用户没提供坐标时,自己瞎猜一个经纬度,比如把 “Tate Modern” 猜成51.5074, -0.1278(这是伦敦市中心的坐标,离泰特现代美术馆差了两公里)。这个错误花了我整整一天才定位到。教训就是:语义层的“灵活性”,必须建立在“确定性”的基石之上。宁可让功能少一点,也不能让边界模糊一点。所以现在,所有涉及坐标的输入,都必须经过 Disambiguator 层的严格校验,Router 层传给 Planner 的,永远是干净的、带唯一 ID 的Place对象,而不是一个可能出错的字符串。

2.3 工具调用(Tool Use):不是“调用函数”,而是“发起一次结构化对话”

很多初学者会把 Tool Use 理解为“让 LLM 写一行 Python 代码去调用requests.get()”。这是巨大的误解。真正的 Tool Use,是让 LLM 生成一个符合预设 JSON Schema 的、结构化的、可被程序无歧义解析的字符串。这个字符串,本质上是一次“对话请求”,而不是一次“函数执行”。

Anthropic 的tool_use机制,其精妙之处在于stop_reason字段。当 LLM 的输出流中出现一个tool_use块时,客户端(也就是你的 Python 代码)会立刻暂停,提取出其中的name(工具名)和input(参数字典),然后调用你预先注册好的对应函数。这个过程,完全脱离了 LLM 的控制。LLM 只负责“提议”,你负责“执行”。

这就引出了一个关键的设计权衡:谁来负责“解释”工具的输出?在 London Commute Agent 中,我做了明确的划分:Planner 和 Disambiguator 这两个“执行层”Agent,它们的工具输出,不做任何解释,原封不动地返回给 Router。只有 Router 层,在收到所有子任务的结果后,才会启动一次新的 LLM 调用,把所有原始数据(包括 TfL API 返回的完整 JSON、Disambiguator 返回的坐标列表、Folium 生成的地图 HTML)作为上下文,让 LLM 去“总结”、“润色”、“生成自然语言描述”。

这个设计的好处是双重的。第一,性能可控。Planner 工具调用 TfL API 后,可能返回 5MB 的 JSON 数据。如果让 LLM 去“理解”这 5MB,光是 token 消耗就足以让你破产。而把它原样返回,Router 层只需要读取其中几个关键字段(比如journeyResults[0].duration)来做决策,效率极高。第二,责任清晰。当最终生成的地图上某条路线画错了,你可以 100% 确定,问题要么出在 Planner 层的JourneyPlannerSearchPayloadProcessor解析逻辑有 bug,要么出在 Output Artifact 层的draw_map_for_plan渲染逻辑有 bug。你永远不需要怀疑“是不是 LLM 在中间‘理解’错了什么”。

提示:在Engine类的process方法里,what_does_ai_say函数的递归调用是有严格条件的。它只在response.stop_reason == "tool_use"时才触发下一轮调用,并且会把上一轮的tool_result作为新的MessageParam加入MessageStack。这个设计确保了“调用-返回-再思考”的链条是原子的、可追溯的。我建议你在自己的项目里,一定要给这个递归加上深度限制(比如最多 3 层),否则一个配置错误的工具描述,可能会导致 LLM 陷入无限循环调用。

3. 核心模块实现:从抽象概念到可运行代码的每一步

3.1 Router Agent:总控大脑的“最小可行智能”

Router Agent 是整个系统的门面,也是最容易被过度设计的部分。我的经验是:给 Router 的 Prompt 越简单,系统越健壮。它的核心 Prompt 模板(用 Jinja2 管理)只有不到 20 行,核心思想就一句话:“你是一个伦敦通勤规划助手。你的工作不是计算路线,而是理解用户需求,并将需求分解为一系列明确的、可执行的子任务。你只能调用以下三个工具:disambiguate_place(用于解析模糊地名)、compute_journey_plans(用于计算路线)、output_artefacts(用于生成最终成果)。请严格遵循工具的输入 Schema。”

Router 的process方法,其主干逻辑异常清晰:

  1. 接收输入:获取用户原始请求字符串。
  2. 初步解析:用正则表达式粗略提取时间(6 am,early evening)、模式偏好(prefer cycling,avoid stairs)等,但这只是辅助,不作为决策依据。
  3. 触发工具调用:这是最关键的一步。Router 会构造一个MessageStack,其中包含:
    • system_prompt:上面提到的 Jinja2 模板渲染结果。
    • user_message:用户的原始请求。
    • tool_spec:从SubTaskAgentToolSet.tool_spec获取的、包含所有可用工具定义的完整 JSON Schema。
  4. 等待并处理响应:调用what_does_ai_say。如果响应是stop_reason == "tool_use",则提取nameinput,执行对应工具函数,并将结果作为ToolResultBlockParam加入MessageStack,然后立即再次调用what_does_ai_say,让 LLM 看到工具返回的结果,并决定下一步做什么(是再调用一个工具,还是直接生成最终回复)。

这个“调用-等待-再调用”的循环,是 Router 智能的全部来源。它没有自己的知识库,没有自己的算法,它的“智能”完全来自于对工具集的精准调度。我曾经尝试给 Router 加一个“记忆”功能,让它记住用户上次说的 “home” 是 Baker Street,结果发现,这不仅增加了复杂度,还引入了状态同步的 bug(比如多个用户并发请求时,记忆会串)。最终,我选择彻底放弃“记忆”,每次请求都当作全新的、独立的会话来处理。这反而让系统更简单、更可靠。

3.2 Planner Agent 与 TfL API 的“切片”艺术:为什么不能直接对接官方 API?

TfL Journey API 是一个强大但极其复杂的系统。它的请求体(Request Body)支持数十个可选参数,响应体(Response Body)则是一个深度嵌套的 JSON,包含了从天气预报到列车车厢拥挤度的海量信息。直接把这个庞然大物扔给 LLM,无异于让一个高中生去阅读《大英百科全书》的全部索引,然后让他回答“牛顿在哪一年出生”。

我的解决方案是“API 切片”(API Slicing)。这不是一个技术术语,而是我从软件工程里借来的实践智慧:为每一个具体的、高频的使用场景,创建一个极简的、语义清晰的“前端”接口。在 London Commute Agent 中,我为 Planner Agent 创建了三个核心“切片”:

切片名称对应的 TfL API 功能封装后的输入参数封装后的输出
JourneyPlannerSearch/journey/journeyResultsorigin: str,destination: str,time: str,timeIs: str ("DEPARTURE" or "ARRIVAL")Journey对象列表
JourneyPlannerSearchParams参数验证与标准化origin,destination,time,timeIs验证通过的、格式统一的参数字典
JourneyPlannerSearchPayloadProcessor响应解析与数据提取TfL API 原始 JSON 响应JourneyPlan对象(只包含duration,legs,summary等关键字段)

这个“切片”过程,是整个项目里我投入精力最多、也收获最大的部分。JourneyPlannerSearchPayloadProcessor的代码,看起来就像一个繁琐的 JSON 解析器,但它解决了最致命的问题:确定性。TfL API 的响应结构并非 100% 稳定。有时journeyResults是一个数组,有时它是一个对象(当只有一个结果时)。有时legs数组里会有walkingtubebus,有时还会冒出一个cableCar(缆车)。PayloadProcessor的唯一使命,就是把这些不确定性,统统抹平,输出一个结构绝对稳定、字段绝对存在的Journey对象。

# 这是 JourneyPlannerSearchPayloadProcessor 的核心逻辑片段 def process_response(self, raw_json: dict) -> List[Journey]: journeys = [] # 强制将 journeyResults 规范化为 list journey_results = raw_json.get("journeyResults", []) if not isinstance(journey_results, list): journey_results = [journey_results] for jr in journey_results: # 强制提取 legs,如果不存在则创建空列表 legs = jr.get("legs", []) if not isinstance(legs, list): legs = [] # 构建 Plan 对象,只取我们关心的字段 plan = Plan( duration=jr.get("duration", 0), summary=jr.get("summary", {}).get("text", ""), legs=[self._parse_leg(l) for l in legs] # _parse_leg 再做一层安全解析 ) journeys.append(Journey(plans=[plan])) return journeys

这段代码的精髓,在于每一个get()调用后面,都跟着一个类型检查和默认值兜底。它不假设 API 会返回什么,它只保证自己输出的东西,永远是List[Journey]。正是这种“防御性编程”,让 Planner Agent 成为了整个系统中最可靠的环节。当你看到一张漂亮的 Folium 地图时,背后是这套严谨的“切片”逻辑在默默支撑。

3.3 Output Artifact Agent:如何把数据变成“看得见、摸得着”的成果

Output Artifact Agent 是项目的“临门一脚”,也是用户感知价值最直接的地方。它不负责思考,只负责执行。它的三个核心工具,代表了三种不同的“成果交付”范式:

  1. draw_map_for_plan:可视化的力量这个工具的输入 Schema 明确要求journey_indexplan_index,这确保了它永远只处理一个具体的、已计算好的Plan对象。它的核心逻辑是遍历Plan.legs,为每一种交通模式(tube,bus,walking,cycling)分配一个独特的颜色('red','blue','green','orange'),然后用 Folium 的PolyLine绘制每一段轨迹。最关键的一行代码是:

    folium.PolyLine(locations=leg_coordinates, color=mode_color, weight=5, opacity=0.8).add_to(m)

    这行代码把抽象的经纬度坐标,变成了屏幕上一条清晰、粗壮、半透明的彩色线条。用户一眼就能看出,“哦,这段是坐地铁,这段是走路,这段是骑车”。可视化不是锦上添花,它是降低认知门槛的刚需。

  2. generate_calendar_event:无缝融入生活这个工具生成.ics文件,其核心是ics库。它把Plan.duration转换成start_timeend_time,把Plan.summary作为事件标题,把Plan.legs[0].departurePoint.commonName作为地点。生成的.ics文件,用户双击就能导入 Outlook 或 Apple Calendar,设置提醒。这一步,把 AI 的“规划”行为,无缝衔接到用户的“执行”行为中,完成了从“知道怎么做”到“真的去做”的闭环。

  3. generate_text_description:让机器说人话这是最考验 Prompt Engineering 的地方。它的输入不是一个空字符串,而是一个结构化的input_structured,里面包含了journey_indexplan_index。它的 Prompt 模板(Jinja2)是这样设计的:

    You are a friendly London tour guide. Describe the following journey plan in simple, step-by-step English, suitable for a non-native speaker. Journey: {{ journey.summary }} Total Duration: {{ journey.duration }} minutes. Steps: {% for leg in plan.legs %} - {{ loop.index }}. {{ leg.mode.name|title }} from {{ leg.departurePoint.commonName }} to {{ leg.arrivalPoint.commonName }} ({{ leg.duration }} mins). {% endfor %}

    这个模板的关键,在于它把JourneyPlan对象的属性,直接暴露给了 LLM 的渲染引擎。LLM 不需要再去“理解”JSON,它只需要做一件事:把模板里的占位符,替换成对象里现成的字符串。这极大地降低了 LLM 出错的概率,也让生成的文本充满了人情味和可读性。

注意:generate_text_description工具的输出,是最终呈现给用户的“摘要”,而不是原始的Plan对象。这意味着,Router Agent 在调用它之前,必须已经通过get_computed_journey_plan工具,把Plan对象从内存中取出来了。这个“先取数据,再生成描述”的两步走策略,是保证最终输出质量的基石。

4. 实操过程详解:从零开始搭建你的第一个通勤 Agent

4.1 环境准备与依赖安装:避开那些“看似无害”的坑

在开始写代码之前,环境配置是第一个也是最重要的关卡。London Commute Agent 的依赖看似简单,但有几个“坑”是我在实战中反复踩过的,必须提前预警。

Python 版本:强烈推荐使用Python 3.10 或 3.11。不要用 3.12,因为 Anthropic 的anthropic库在 3.12 上存在一个与httpx库的兼容性问题,会导致ClientError。也不要固执地用 3.9,因为pydanticv2 的一些高级特性(比如RootModel)在 3.9 上支持不完善,而JourneyPlan数据类大量使用了这些特性。

核心依赖清单requirements.txt):

anthropic==0.35.0 folium==0.14.0 ics==0.7.2 jinja2==3.1.3 pydantic==2.6.4 requests==2.31.0

最关键的依赖:pydantic。它不是用来做数据验证的,而是用来构建JourneyPlan这些“活”的数据类的。pydantic.BaseModel的魔力在于,它能让一个普通的 Python 字典,瞬间变成一个拥有类型提示、自动验证、甚至 JSON 序列化能力的“智能对象”。例如,Plan类的定义:

from pydantic import BaseModel from typing import List, Optional class Leg(BaseModel): mode: str departurePoint: dict arrivalPoint: dict duration: int class Plan(BaseModel): duration: int summary: str legs: List[Leg] class Journey(BaseModel): plans: List[Plan]

有了这个定义,当你从 TfL API 拿到一个原始 JSON 字典raw_data时,你只需要写plan = Plan(**raw_data),Pydantic 就会自动帮你完成类型转换、缺失字段填充(用默认值)、以及非法数据的报错。这比手写if 'duration' in raw_data: ...要优雅、安全、高效一万倍。

环境变量:所有敏感信息,必须通过环境变量注入。在项目根目录创建.env文件:

# .env ANTHROPIC_API_KEY=your_actual_api_key_here TFL_APP_ID=your_tfl_app_id TFL_APP_KEY=your_tfl_app_key

然后在 Python 代码中,用os.getenv("ANTHROPIC_API_KEY")来读取。绝对不要把 API Key 硬编码在代码里,这是安全红线。

4.2 构建 Router Agent:从build_agents.py开始的第一行代码

build_agents.py是整个项目的“心脏起搏器”。它负责实例化所有 Agent,并将它们连接起来。我们从最顶层的agent_router开始:

# build_agents.py from engine import Engine from toolset import SubTaskAgentToolSet from jinja2 import Environment, FileSystemLoader import os # 1. 加载系统 Prompt 模板 PROMPT_FOLDER = os.path.join(os.path.dirname(__file__), "prompts") system_prompt_template = Environment( loader=FileSystemLoader(PROMPT_FOLDER) ).get_template("router_system_prompt.j2") # 2. 创建工具集 toolset = SubTaskAgentToolSet() # 3. 创建 Router Engine agent_router = Engine( model="claude-3-opus-20240229", system_prompt=system_prompt_template.render(), toolset=toolset )

这段代码的每一行,都对应着一个关键决策:

  • 第 1 步:使用 Jinja2 模板,是为了让 Prompt 可维护、可测试。你可以为不同 Agent 创建不同的模板文件(router_system_prompt.j2,planner_system_prompt.j2),并在其中使用{% if condition %}...{% endif %}进行条件渲染。这比在 Python 字符串里拼接要专业得多。
  • 第 2 步SubTaskAgentToolSet是一个继承自ToolSet的类,它内部定义了disambiguate_place,compute_journey_plans,output_artefacts这三个方法。每个方法的签名,必须与你在 JSON Schema 中定义的input_schema完全一致。这是保证“LLM 提议”和“程序执行”能严丝合缝对接的物理基础。
  • 第 3 步Engine类的初始化,是整个 Tool Use 流程的起点。它把模型、Prompt、工具集这三样东西,打包成了一个可以被process方法调用的“黑盒”。

接下来,你需要在toolset.py中定义SubTaskAgentToolSet

# toolset.py from toolset import ToolSet from planner import compute_journey_plans, get_computed_journey, get_computed_journey_plan from disambiguator import disambiguate_place from output_artifact import draw_map_for_plan, generate_calendar_event, generate_text_description class SubTaskAgentToolSet(ToolSet): def disambiguate_place(self, input_prompt: str) -> dict: return disambiguate_place(input_prompt) def compute_journey_plans(self, input_prompt: str, input_structured: dict) -> dict: return compute_journey_plans(input_prompt, input_structured) def output_artefacts(self, input_prompt: str, input_structured: dict) -> dict: return output_artefacts(input_prompt, input_structured)

注意input_structured参数的类型注解。它必须是dict,因为 LLM 生成的tool_use输入,就是一个 JSON 对象,会被anthropic客户端自动解析为 Python 字典。如果你在这里写成str,程序会在运行时报错。

4.3 实现 Planner Agent:与 TfL API 的第一次握手

Planner Agent 的核心是compute_journey_plans函数。它的工作流程是典型的“请求-响应-处理”三步曲:

# planner.py import requests import json from journey_planner_search import JourneyPlannerSearch from journey_planner_search_payload_processor import JourneyPlannerSearchPayloadProcessor def compute_journey_plans(input_prompt: str, input_structured: dict) -> dict: """ 主入口函数。接收 Router 的请求,调用 TfL API,并返回结构化元数据。 """ # Step 1: 解析 input_structured,获取用户偏好 # 这里可以提取 time, timeIs, modePreferences 等 params = { "origin": input_structured.get("origin", ""), "destination": input_structured.get("destination", ""), "time": input_structured.get("time", ""), "timeIs": input_structured.get("timeIs", "DEPARTURE") } # Step 2: 创建搜索器并执行 search = JourneyPlannerSearch() raw_response = search.execute(params) # Step 3: 处理原始响应 processor = JourneyPlannerSearchPayloadProcessor() journeys = processor.process_response(raw_response) # Step 4: 返回元数据(不是全部数据!) return { "journey_count": len(journeys), "plan_counts_per_journey": [len(j.plans) for j in journeys], "summary": f"Found {len(journeys)} journey options." }

这个函数的返回值,是一个轻量级的、只包含摘要信息的字典。它告诉 Router:“我找到了 3 个可能的路线,第一个路线有 2 个备选方案,第二个有 4 个……”。Router 收到这个摘要后,会根据用户偏好(比如“我要最快的”),再调用get_computed_journey_plan,传入journey_index=0, plan_index=1,去获取那个具体方案的全部细节。

JourneyPlannerSearch.execute()方法,才是真正与 TfL API 对话的地方。它需要构造一个符合规范的 HTTP 请求:

# journey_planner_search.py import requests import os class JourneyPlannerSearch: BASE_URL = "https://api.tfl.gov.uk" def execute(self, params: dict) -> dict: # 构造查询参数 query_params = { "app_id": os.getenv("TFL_APP_ID"), "app_key": os.getenv("TFL_APP_KEY"), "from": params["origin"], "to": params["destination"], "time": params["time"], "timeIs": params["timeIs"] } # 发送 GET 请求 response = requests.get( f"{self.BASE_URL}/Journey/JourneyResults", params=query_params, timeout=30 ) # 关键:必须检查状态码 if response.status_code != 200: # 如果是 400,说明参数错误;如果是 500,说明 TfL 服务端问题 raise Exception(f"TfL API Error: {response.status_code} - {response.text}") return response.json()

这里有一个血泪教训:永远不要忽略timeoutstatus_code检查。TfL API 在高峰期响应缓慢,如果没有timeout,你的整个 Agent 会卡死在那里,直到超时(默认可能是几分钟)。而status_code检查,则是区分“用户输错了地名”和“TfL 服务器挂了”的唯一方式。前者你应该返回一个友好的提示(“抱歉,我没找到叫 ‘X’ 的地方,请确认拼写”),后者则应该返回一个系统错误(“服务暂时不可用,请稍后再试”)。

4.4 输出成果:生成一张“会说话”的地图

draw_map_for_plan是整个项目里最“赏心悦目”的部分。它的实现,完美体现了“工具调用”的威力:LLM 负责“决策”(调用哪个工具、传什么参数),而具体的、繁重的、需要精确控制的绘图工作,则由专业的 Python 库(Folium)来完成。

# output_artifact.py import folium from folium.plugins import MarkerCluster from typing import List, Tuple def draw_map_for_plan(journey_index: int, plan_index: int, browser_display: bool = True) -> str: """ 为指定的 Plan 生成交互式地图。 """ # Step 1: 从 JourneyMaker 的全局状态中获取 Plan 对象 # (这里省略了 JourneyMaker 的单例实现细节) journey_maker = get_journey_maker_instance() plan = journey_maker.get_journey_plan(journey_index, plan_index) # Step 2: 创建地图,中心点设为起点 start_lat = plan.legs[0].departurePoint.lat start_lon = plan.legs[0].departurePoint.lon m = folium.Map(location=[start_lat, start_lon], zoom_start=12, tiles="CartoDB positron") # Step 3: 为每一段行程绘制不同颜色的线 mode_colors = {"tube": "red", "bus": "blue", "walking": "green", "cycling": "orange"} for i, leg in enumerate(plan.legs): # 获取该段行程的所有坐标点(简化版,实际需从 TfL API 获取 polyline) # 这里用一个模拟的坐标列表代替 coordinates = _simulate_leg_coordinates(leg) color = mode_colors.get(leg.mode.name, "gray") folium.PolyLine( locations=coordinates, color=color, weight=5, opacity=0.8, popup=f"Leg {i+1}: {leg.mode.name.title()} ({leg.duration} mins)" ).add_to(m) # Step 4: 添加起点和终点标记 folium.Marker( [start_lat, start_lon], popup="Start: " + plan.legs[0].departurePoint.commonName, icon=folium.Icon(color="green", icon="play") ).add_to(m) end_lat = plan.legs[-1].arrivalPoint.lat end_lon = plan.legs[-1].arrivalPoint.lon folium.Marker( [end_lat, end_lon], popup="End: " + plan.legs[-1].arrivalPoint.commonName, icon=folium.Icon(color="red", icon="flag") ).add_to(m) # Step 5: 保存或显示 map_path = f"map_j{jour
http://www.jsqmd.com/news/966206/

相关文章:

  • 别再混淆了!图形学视角下的ECEF与ENU转换:从世界坐标到局部坐标的矩阵推导(附WebGL/Three.js示例)
  • 可解释AI工程实践:从算法选型到业务落地的7个关键步骤
  • 保姆级教程:用Python+巴法云(Bemfa)搞定智能家居远程控制(TCP/MQTT双协议对比)
  • AI编排实战:MuleSoft+LangChain构建企业级AI连接层
  • AI辅助阅读协议:结构化四阶段认知协作框架
  • AI赋能终端操作:基于快马让Kimi帮你自动生成xshell8复杂命令
  • PINN实战三件套:Burgers激波、热传导、浅水方程的端到端求解与动态可视化代码包
  • 从笛卡尔到‘玩偶屋研究’:程序员如何用哲学思维提升技术文档写作?
  • 高效文件夹分类整理方法与工具推荐
  • RAG原理解析:检索增强生成如何解决知识密集型NLP的事实一致性问题
  • 爬虫+GloVe+LSTM实现名言生成:短文本风格化序列建模实战
  • 用Python的soundcard库+DG1062信号源,实测你的电脑声卡到底有多“Hi-Fi”?
  • 告别手动复制链接!手把手教你配置Jupyter Notebook自动打开Chrome/Edge浏览器(附路径查找技巧)
  • GPT-4稀疏激活真相:万亿参数模型的动态路由与工程落地
  • 用Python+Flask手把手复刻‘按钮,按钮’交互实验,并聊聊A/B测试的伦理边界
  • 从.h到.hpp:聊聊C++头文件后缀演变史与模板分离编译的坑
  • MuleSoft AI编排:企业级LLM集成的可审计、可治理实践
  • ABAQUS建模避坑指南:Part模块里那些“反直觉”的操作与高效技巧(Ctrl+Alt+鼠标)
  • 别再写重复的点击事件了!用JavaScript原生API重构你的Tab切换逻辑(附完整代码)
  • Roblox Studio新手避坑指南:从界面布局到第一个可交互模型的完整流程
  • 从《信息学奥赛一本通》的简单计算器题,聊聊编程中如何处理用户输入和边界情况
  • MuleSoft企业级AI编排:构建LLM与ERP/SAP/CRM的语义中枢
  • 多维聚合数据操纵:超越GROUP BY的维度折叠与指标重算
  • 从‘A’到‘ÿ’:深入理解ASCII码控制字符与扩展字符的‘前世今生’
  • Windows平台通用摄像头控制工具:C#实现拍照、录像与实时预览,兼容多数USB及网络摄像头
  • 数据科学如何驱动商业决策:从模型精度到业务价值的思维跃迁
  • 实战arm7物联网终端:快马ai生成从传感器采集到数据上报的完整代码
  • AI驱动的数字营销新范式(CSDN官方未披露的算法逻辑+客户分层模型V2.3)
  • Abaqus 2023版扫掠网格划分避坑指南:从带孔底板到不规则耳朵,一次讲清切割逻辑与质量检查
  • 反人类:VS新插件取工程名称要500个字代码,VisualStudio.Extensibility