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

发布时间:2026/6/7 5:25:48
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) API、Semantic Layer语义层。它要干的事是让“我想八点十五从家出发去泰特现代美术馆最好别坐地铁能骑车就骑车”这样一句充满模糊性、文化暗示和隐含约束的人类语言被精准地拆解、翻译、调度最终生成一张带颜色编码路线的 Folium 地图、一份可导入日历的 ICS 文件以及一段连保洁阿姨都能听懂的语音导航稿。我试过很多种方案。最早用纯 Prompt Engineering把 TfL API 文档全文喂给 Claude让它“理解”参数含义。结果呢它能把fromPlace和toPlace解析成伦敦邮编但一旦遇到“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%。它开始在departurePoint和arrivalPoint之间随机混淆或者把duration秒当成分钟来用。这不是模型能力问题而是人类工程师没给它一个“舒适区”。就像你不会让一个刚入职的实习生同时负责财务审计、法务合同和产品设计你得先让他从整理会议纪要开始。所以这个架构的每一层都是一个“认知减压阀”Router 层只处理“是什么”What不处理“怎么做”How。它像一个前台接待只问“您要去哪儿几点出发有什么特殊要求”然后把问题分发给对应部门。Planner 层只处理“怎么算”How。它拿到 Router 分发的标准化坐标和时间调用 TfL Journey API把原始 JSON 响应封装成Journey和Plan两个自定义数据类。它不关心地图长什么样也不管用户想不想看日历。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、输入 SchemaInput 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.” 这句话不是随便写的。它明确告诉 LLM1你只能处理自然语言2你不能自己去查坐标那是 Disambiguator 的事3你只关注四个要素起点、终点、时间、偏好。这就把 LLM 的“自由发挥空间”锁死了。更关键的是input_schema里的properties。它规定input_prompt必须是string类型input_structured必须是object类型且内部必须包含journey_index和plan_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方法其主干逻辑异常清晰接收输入获取用户原始请求字符串。初步解析用正则表达式粗略提取时间6 am,early evening、模式偏好prefer cycling,avoid stairs等但这只是辅助不作为决策依据。触发工具调用这是最关键的一步。Router 会构造一个MessageStack其中包含system_prompt上面提到的 Jinja2 模板渲染结果。user_message用户的原始请求。tool_spec从SubTaskAgentToolSet.tool_spec获取的、包含所有可用工具定义的完整 JSON Schema。等待并处理响应调用what_does_ai_say。如果响应是stop_reason tool_use则提取name和input执行对应工具函数并将结果作为ToolResultBlockParam加入MessageStack然后立即再次调用what_does_ai_say让 LLM 看到工具返回的结果并决定下一步做什么是再调用一个工具还是直接生成最终回复。这个“调用-等待-再调用”的循环是 Router 智能的全部来源。它没有自己的知识库没有自己的算法它的“智能”完全来自于对工具集的精准调度。我曾经尝试给 Router 加一个“记忆”功能让它记住用户上次说的 “home” 是 Baker Street结果发现这不仅增加了复杂度还引入了状态同步的 bug比如多个用户并发请求时记忆会串。最终我选择彻底放弃“记忆”每次请求都当作全新的、独立的会话来处理。这反而让系统更简单、更可靠。3.2 Planner Agent 与 TfL API 的“切片”艺术为什么不能直接对接官方 APITfL 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 响应Journey和Plan对象只包含duration,legs,summary等关键字段这个“切片”过程是整个项目里我投入精力最多、也收获最大的部分。JourneyPlannerSearchPayloadProcessor的代码看起来就像一个繁琐的 JSON 解析器但它解决了最致命的问题确定性。TfL API 的响应结构并非 100% 稳定。有时journeyResults是一个数组有时它是一个对象当只有一个结果时。有时legs数组里会有walking、tube、bus有时还会冒出一个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( durationjr.get(duration, 0), summaryjr.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 是项目的“临门一脚”也是用户感知价值最直接的地方。它不负责思考只负责执行。它的三个核心工具代表了三种不同的“成果交付”范式draw_map_for_plan可视化的力量这个工具的输入 Schema 明确要求journey_index和plan_index这确保了它永远只处理一个具体的、已计算好的Plan对象。它的核心逻辑是遍历Plan.legs为每一种交通模式tube,bus,walking,cycling分配一个独特的颜色red,blue,green,orange然后用 Folium 的PolyLine绘制每一段轨迹。最关键的一行代码是folium.PolyLine(locationsleg_coordinates, colormode_color, weight5, opacity0.8).add_to(m)这行代码把抽象的经纬度坐标变成了屏幕上一条清晰、粗壮、半透明的彩色线条。用户一眼就能看出“哦这段是坐地铁这段是走路这段是骑车”。可视化不是锦上添花它是降低认知门槛的刚需。generate_calendar_event无缝融入生活这个工具生成.ics文件其核心是ics库。它把Plan.duration转换成start_time和end_time把Plan.summary作为事件标题把Plan.legs[0].departurePoint.commonName作为地点。生成的.ics文件用户双击就能导入 Outlook 或 Apple Calendar设置提醒。这一步把 AI 的“规划”行为无缝衔接到用户的“执行”行为中完成了从“知道怎么做”到“真的去做”的闭环。generate_text_description让机器说人话这是最考验 Prompt Engineering 的地方。它的输入不是一个空字符串而是一个结构化的input_structured里面包含了journey_index和plan_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 %}这个模板的关键在于它把Journey和Plan对象的属性直接暴露给了 LLM 的渲染引擎。LLM 不需要再去“理解”JSON它只需要做一件事把模板里的占位符替换成对象里现成的字符串。这极大地降低了 LLM 出错的概率也让生成的文本充满了人情味和可读性。注意generate_text_description工具的输出是最终呈现给用户的“摘要”而不是原始的Plan对象。这意味着Router Agent 在调用它之前必须已经通过get_computed_journey_plan工具把Plan对象从内存中取出来了。这个“先取数据再生成描述”的两步走策略是保证最终输出质量的基石。4. 实操过程详解从零开始搭建你的第一个通勤 Agent4.1 环境准备与依赖安装避开那些“看似无害”的坑在开始写代码之前环境配置是第一个也是最重要的关卡。London Commute Agent 的依赖看似简单但有几个“坑”是我在实战中反复踩过的必须提前预警。Python 版本强烈推荐使用Python 3.10 或 3.11。不要用 3.12因为 Anthropic 的anthropic库在 3.12 上存在一个与httpx库的兼容性问题会导致ClientError。也不要固执地用 3.9因为pydanticv2 的一些高级特性比如RootModel在 3.9 上支持不完善而Journey和Plan数据类大量使用了这些特性。核心依赖清单requirements.txtanthropic0.35.0 folium0.14.0 ics0.7.2 jinja23.1.3 pydantic2.6.4 requests2.31.0最关键的依赖pydantic。它不是用来做数据验证的而是用来构建Journey和Plan这些“活”的数据类的。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_KEYyour_actual_api_key_here TFL_APP_IDyour_tfl_app_id TFL_APP_KEYyour_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( loaderFileSystemLoader(PROMPT_FOLDER) ).get_template(router_system_prompt.j2) # 2. 创建工具集 toolset SubTaskAgentToolSet() # 3. 创建 Router Engine agent_router Engine( modelclaude-3-opus-20240229, system_promptsystem_prompt_template.render(), toolsettoolset )这段代码的每一行都对应着一个关键决策第 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: fFound {len(journeys)} journey options. }这个函数的返回值是一个轻量级的、只包含摘要信息的字典。它告诉 Router“我找到了 3 个可能的路线第一个路线有 2 个备选方案第二个有 4 个……”。Router 收到这个摘要后会根据用户偏好比如“我要最快的”再调用get_computed_journey_plan传入journey_index0, plan_index1去获取那个具体方案的全部细节。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, paramsquery_params, timeout30 ) # 关键必须检查状态码 if response.status_code ! 200: # 如果是 400说明参数错误如果是 500说明 TfL 服务端问题 raise Exception(fTfL API Error: {response.status_code} - {response.text}) return response.json()这里有一个血泪教训永远不要忽略timeout和status_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_start12, tilesCartoDB 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( locationscoordinates, colorcolor, weight5, opacity0.8, popupfLeg {i1}: {leg.mode.name.title()} ({leg.duration} mins) ).add_to(m) # Step 4: 添加起点和终点标记 folium.Marker( [start_lat, start_lon], popupStart: plan.legs[0].departurePoint.commonName, iconfolium.Icon(colorgreen, iconplay) ).add_to(m) end_lat plan.legs[-1].arrivalPoint.lat end_lon plan.legs[-1].arrivalPoint.lon folium.Marker( [end_lat, end_lon], popupEnd: plan.legs[-1].arrivalPoint.commonName, iconfolium.Icon(colorred, iconflag) ).add_to(m) # Step 5: 保存或显示 map_path fmap_j{jour