从一个简单的需求看langchain的Prompt和Agent
一. 需求
最近想实现一个简单的功能:用户输入一个餐厅名关键词,agent调用高德地图api,拿到餐厅的一些关键信息,如准确店名、地址(文字地址+经纬度)、评分、人均、营业时间等,接下来agent将接口返回的数据整理,形成更精简的结构化json,存入数据库。只获取餐厅信息并存库是没太多业务价值的,第二步我会给这个agent提供更多全网搜索餐厅信息、评价等工具,让存储的信息不止包含基本信息,还要包含agent处理过的评价。不过第二步是后话了,今天先记录我在做第一步过程中遇到的问题,和我的思考。
二. 初步实现
我先封装了两个请求高德地图api接口的方法,它们属于一个RestaurantPoi类。
1 import requests 2 import os 3 from dotenv import load_dotenv 4 5 load_dotenv() 6 7 class RestautantPoi: 8 """ 9 提供餐厅地图位置等信息查询工具 10 """ 11 12 def get_city_info(self): 13 """ 14 获取当前定位城市 15 """ 16 url = os.getenv('IP_POSITION_URL') 17 try: 18 res = requests.get(url,params={'key': os.getenv('AMAP_API_KEY')}) 19 res.raise_for_status() 20 21 return res.json() 22 except Exception as e: 23 return None 24 25 def amap_search(self, keyword, city=''): 26 """ 27 根据餐厅关键词在地图上搜索店铺 28 """ 29 if not city: 30 ip_city_info = self.get_city_info() 31 city = ip_city_info.get('adcode') if ip_city_info else '' 32 33 url = os.getenv('SEARCH_RESTAURANT_URL') 34 params = { 35 'key': os.getenv('AMAP_API_KEY'), 36 'keywords': keyword, 37 'types': os.getenv('RESTAURANT_TYPE_CODE'), 38 'city': city 39 } 40 try: 41 res = requests.get(url, params=params) 42 res.raise_for_status() 43 44 json = res.json() 45 if json.get('status') == 0: 46 raise Exception(json.get('info')) 47 return json.get('pois') 48 except Exception as e: 49 print(f'搜索遇到错误{e}') 50 return None
然后,我定义了一个langchain_tool,在里面实例化RestaurantPoi类并调用查询方法。
1 from langchain_core.tools import tool as langchain_tool 2 from poi_api import RestautantPoi 3 4 # 定义工具 5 @langchain_tool 6 def search_restaurant(query: str) -> list: 7 """ 8 根据关键字搜索餐厅方法 9 args: 10 query: str 餐厅名关键字 11 returns: 12 rest_info: list 餐厅信息列表 13 """ 14 print(f'\n --- tools: 工具调用:search_restaurant,查询:{query} ---') 15 try: 16 poi_obj = RestautantPoi() 17 rests = poi_obj.amap_search(query) 18 if len(rests) > 0: 19 print('--- 工具调用结束 ---') 20 return rests 21 else: 22 raise Exception('没有获取到餐厅信息') 23 except Exception as e: 24 print(f'--- 餐厅搜索工具出错: {e}') 25 return []
接下来,定义一个agent,传入工具和prompt,期待它返回一个精简后的结构化json。
1 from langchain_openai import ChatOpenAI 2 from langchain_core.messages import SystemMessage 3 from langchain.agents import create_agent 4 5 tools = [search_restaurant] 6 7 # 创建工具调用agent 8 if llm: 9 agent_prompt = SystemMessage(""" 10 你是一个帮助用户搜索并整理餐厅信息的助手。 11 用户输入中会包含餐厅名关键词{keyword}。 12 而你需要使用工具,根据关键词搜索餐厅信息,并从中整理出最匹配的一家。 13 读取并整理餐厅信息,从名字、人均、评分、位置(经纬度)四个方面,进行格式化的输出。 14 仅输出json,不要进行多余文本输出。 15 -------- 16 正常输出example: 17 { 18 'name': '云水谣云南菜', 19 'price': '¥79', 20 'rate': '4.2', 21 'location': '115.984339,40.474397' 22 } 23 --------- 24 部分信息未找到输出example: 25 { 26 'name': '无二和牛烧', 27 'price': '暂无', 28 'rate': '暂无', 29 'location': '115.984339,40.474397' 30 } 31 ---------- 32 工具未找到合适餐厅输出example: 33 { } 34 """) 35 36 agent = create_agent( 37 model=llm, 38 tools=tools, 39 system_prompt=agent_prompt) 40 41 if __name__ == '__main__': 42 res = agent.invoke({"messages": [{"role": "user", "content": "帮我查一下离故宫最近的四季民福烤鸭店"}]}) 43 print('------ Agent 回复: -------') 44 print(res)
补充一下llm的实例化:
1 try: 2 llm = ChatOpenAI(model='gpt-4o',temperature=0) 3 print(f'语言模型初始化成功') 4 except Exception as e: 5 print(f'语言模型初始化失败{e}') 6 llm = None
上面这段定义agent的代码有两个问题,虽然能跑起来,但结果和预期不同,它返回了工具调用的初步结果,没有进行格式化。
三. 问题分析
1. (猜想的)第一个问题
我对大模型function call的理解不够深入。已知大模型可以被理解成一个巨大的文本预测函数,函数的特性是一次调用一次输出,没有定义成递归的前提下,不会自己调用自己,也不会有两次输出。
那么我的prompt是怎么写的呢?我希望大模型先调用工具,拿到工具函数的返回后,再优化这个返回,输出精简json。
但是对于大模型来说,输出工具调用的文本就完成了这一次调用,后面如何处理工具返回的结果,需要再调用一次模型。应该由我来编排,将工具调用的结果合并前面的历史记录,再次传递给大模型,由模型进行处理。这是我这个工具调用初学者犯的一个小白错误。同时,负责判断意图并调用工具的agent(因为后续会增加更多的搜索工具)和负责信息整理优化的agent,应该分开作为两个独立功能的agent来设计,每个agent只负责自己份内的工作,这样理论上效果才最好。
我需要将prompt拆开,工具调用就只进行工具调用的提示;优化json就只进行json优化的提示。
2. (猜想的)第二个问题
注意到我prompt中有一个'keyword',它真的用到了吗?
我虽然使用了SystemMessage,并进行了模版插值,但agent是以
=================== 经过在langchain官网一番查询,以下为更新结论 ===================================
四. 问题结论
先解决简单的问题二:使用TemplatePrompt还是MessagePrompt?
答:TemplatePrompt只适用于链式调用,属于比较旧版本的范式,已经不建议使用了,最新的包括工具调用在内的agent范式都建议使用MessagePrompt。工具调用的返回也是以ToolMessage的形式定义的。
即便是场景比较固定的工作流,也更建议使用MessagePrompt+dynamic_prompt(一种middleware)来构建,方便进行上下文管理,而不是使用template模板插值。
我个人的理解,尽管固定场景(如搜索并保存餐厅信息)看起来是面向用户的一次输入一次输出,但ReAct模式需要agent内部不断循环判断和检查,也是一种“对话”,使用MessagePrompt是合适的。
一句话概括:放弃TemplatePrompt,直接使用MessagePrompt,并多关注新特性,多用用就会有更深的感悟。
问题一:为什么我的agent没有进行工具调用后的处理?
答:我在代码中调用的create_agent是langchain v1.0.0的新特性,它内部使用了LangGraph进行编排,循环调用llm检查是否需要调用工具,如果已经不需要调用工具,结束循环。
既然create_agent已经是循环调用llm了,为什么我的代码只返回了搜索结果,没有进行结构化处理?
请注意上面的描述,create_agent判断是否需要结束循环的条件是:是否还需要调用工具,当 LLM 不再请求工具调用时,agent 就认为任务完成了。langchain建议的解决是:强制让agent在工具返回后再调用一次LLM来进行结果整理。
五. 修改方案
1. 在工具的返回值中向大模型强调需要一步后处理。
上面的第一版代码直接返回了rests,是一个餐厅信息列表。我们只需要改变一下返回值:
1 # 定义工具 2 @langchain_tool 3 def search_restaurant(query: str) -> list: 4 """ 5 根据关键字搜索餐厅方法 6 args: 7 query: str 餐厅名关键字 8 returns: 9 str: 包含餐厅信息列表的一段描述,并给出让LLM进一步处理的指令 10 """ 11 print(f'\n --- tools: 工具调用:search_restaurant,查询:{query} ---') 12 try: 13 poi_obj = RestautantPoi() 14 rests = poi_obj.amap_search(query) 15 if len(rests) > 0: 16 import json 17 rest_data = json.dumps(rests, ensure_ascii=False) 18 print('--- 工具调用结束 ---') 19 return f'找到的餐厅信息{rest_data}\n\n请你根据上面的餐厅信息,整理出最匹配的一家,仅输出json格式' 20 else: 21 raise Exception('没有获取到餐厅信息') 22 except Exception as e: 23 print(f'--- 餐厅搜索工具出错: {e}') 24 return f'错误:{e},请输出空json: {{}}'
即可得到正确回复:
语言模型初始化成功 --- tools: 工具调用:search_restaurant,查询:四季民福烤鸭店 --- --- 工具调用结束 --- ------ Agent 回复: ------- { "name": "四季民福烤鸭店(故宫店)", "price": "¥134", "rate": "4.7", "location": "116.402873,39.914525" }
-- 插播一下,langchain agent 返回值的类型是一个含有messages字段的dict,messages是一个消息列表,结构如下:
# 返回值结构: { "messages": [ HumanMessage(content="帮我查一下..."), AIMessage(content="Let me search..."), ToolMessage(content="found: {...}", tool_call_id="call_123"), AIMessage(content='{"name": "四季民福烤鸭店", ...}') # ← 最后的回复 ] }
所以取值逻辑应修改为:res['messages'][-1].content(注意最后一步用 '.' 来取值,因为AIMessage消息类型不是dict)。
但这种改工具返回值的方法不够优美,更推荐下面的方法:
2. 修改SystemPrompt:
1 agent_prompt = SystemMessage(""" 2 你是一个帮助用户搜索并整理餐厅信息的助手。 3 ** 重要流程 ** 4 1. 当用户询问时,你需要使用search_restaurant工具搜索餐厅信息。 5 2. 获取工具返回的餐厅列表后,不要停止流程!不要直接返回原始数据。 6 3. 你必须分析这些数据,选出出最匹配用户描述的一家。 7 4. 最后仅输出整理后的 JSON 格式。 8 ** 输出格式要求 ** 9 - 仅输出JSON,不要任何多余文本。 10 - JSON 字段:name(餐厅名), price(人均), rate(评分), location(坐标), open_time(营业时间) 11 - 如果某一个或多个字段未找到,用“暂无”表示。 12 ** 示例 ** 13 正常: 14 { 15 'name': '云水谣云南菜', 16 'price': '¥79', 17 'rate': '4.2', 18 'location': '115.984339,40.474397' 19 } 20 未找到餐厅: 21 {} 22 """)
在prompt中明确规定流程,并另外写明输出格式。
对比一开始的prompt:
虽然规定了要json格式的输出,但工具返回的也是json,LLM可能会直接判断json中包含了需要的信息,不需要进一步使用工具,就返回了。
新版prompt明确提示了,使用过工具后不要直接返回,要针对结果进行格式优化,所以结果正确了。
但是,回顾agent的定义原则,没有工具可调用就结束。我有一个符合agent结束条件的新想法:写一个专门的agent负责结果格式化,把它作为工具传给当前的agent,现在的agent只负责使用工具。
3. 定义负责格式化结果的子agent
这种方法的好处有:
- 职责清晰:主 agent 只搜索,子 agent 只格式化
- ✅ 可维护:改需求时只需改对应 agent
- ✅ 可扩展:将来可以加更多 agent(翻译、存储等)
- ✅ 符合官方推荐
实现:
1 from dotenv import load_dotenv 2 3 from langchain_google_genai import ChatGoogleGenerativeAI 4 from langchain_openai import ChatOpenAI 5 from langchain_core.messages import SystemMessage 6 from langchain_core.tools import tool 7 from langchain.agents import create_agent, AgentState 8 9 from poi_api import RestautantPoi 10 11 load_dotenv() 12 13 try: 14 llm = ChatOpenAI(model='gpt-4o',temperature=0) 15 print(f'语言模型初始化成功') 16 except Exception as e: 17 print(f'语言模型初始化失败{e}') 18 llm = None 19 20 # 定义格式化处理子agent 21 format_sub_agent = create_agent( 22 model=llm, 23 system_prompt=""" 24 你是一个JSON格式化专家。 25 你的任务: 26 1. 接收传入的json或类json的餐厅信息列表。 27 2. 根据用户输入的描述,从输入餐厅列表中选出最匹配用户需求的餐厅。 28 3. 把复杂的餐厅信息整合成如下输出要求的信息,进行标准化输出。 29 4. 最后仅输出符合要求的JSON文本。 30 ** 输出格式要求 ** 31 - 仅输出JSON,不要任何多余文本。 32 - JSON 字段:name(餐厅名), price(人均), rate(评分), location(坐标), open_time(营业时间) 33 - 如果某一个或多个字段未找到,用“暂无”表示。 34 ** 示例 ** 35 正常: 36 { 37 'name': '云水谣云南菜', 38 'price': '¥79', 39 'rate': '4.2', 40 'location': '115.984339,40.474397' 41 } 42 未找到餐厅: 43 {} 44 """ 45 ) 46 47 # 定义工具 48 @tool 49 def search_restaurant(query: str) -> list: 50 """ 51 根据关键字搜索餐厅方法 52 args: 53 query: str 餐厅名关键字 54 returns: 55 list: 餐厅信息列表 56 """ 57 print(f'\n --- tools: 工具调用:search_restaurant,查询:{query} ---') 58 try: 59 poi_obj = RestautantPoi() 60 rests = poi_obj.amap_search(query) 61 if len(rests) > 0: 62 import json 63 print('--- 工具调用结束 ---') 64 return json.dumps(rests, ensure_ascii=False) 65 else: 66 return json.dumps([]) 67 except Exception as e: 68 print(f'--- 餐厅搜索工具出错: {e}') 69 return json.dumps({'error': e}) 70 71 @tool("format_restaurant_data") 72 def format_restaurant_json( 73 raw_data: str, 74 runtime # 这里本来是有一个langchain_core.tools.RuntimeTool类型的,但是新版没了,只能暂时空着,还好也能用 75 ) -> str: 76 """ 77 接收原始餐厅搜索结果,格式化为标准JSON 78 Args: 79 raw_data: 搜索到的原始餐厅数据(json字符串) 80 runtime: 包含主Agent状态的运行时对象 81 Returns: 82 格式化后的json串,只包含一家餐厅的必要信息 83 """ 84 print('----调用结果格式化方法----') 85 messages = runtime.state.get('messages', []) 86 # 寻找用户的原始需求,即第一条HumanMessage 87 user_request = None 88 for msg in messages: 89 if msg.type == 'human' or msg.rile == 'user': 90 user_request = msg.content 91 break 92 93 context_prompt = f"""用户原始需求:{user_request} 94 \n原始搜索数据:\n{raw_data}\n 95 现在请根据用户的需求,从这些数据中选出最匹配的一家餐厅,并格式化输出。 96 """ 97 res = format_sub_agent.invoke({'messages': [{'role': 'user', 'content': context_prompt}]}) 98 res = res['messages'][-1].content 99 print('-----结果格式化方法返回:----') 100 print(res) 101 return res 102 tools = [search_restaurant, format_restaurant_json] 103 104 # 创建工具调用agent 105 if llm: 106 agent_prompt = SystemMessage(""" 107 你是一个帮助用户搜索并整理餐厅信息的助手。 108 ** 重要流程 ** 109 1. 当用户询问时,你需要使用search_restaurant工具搜索餐厅信息。 110 2. 获取工具返回的餐厅列表后,你要立即调用format_restaurant_json工具进行格式化。 111 3. 最后仅输出格式化后的一家餐厅信息JSON。 112 """) 113 114 agent = create_agent( 115 model=llm, 116 tools=tools, 117 system_prompt=agent_prompt) 118 119 if __name__ == '__main__': 120 res = agent.invoke({"messages": [{"role": "user", "content": "帮我查一下离海淀黄庄最近的四季民福烤鸭店"}]}) 121 print('------ Agent 回复: -------') 122 print(res['messages'][-1].content)
最终成功输出啦!
需要注意的点是:子agent要知道用户意图,format方法不能只传入json,还要传入运行时上下文(或者在搜索工具里面多返回一个用户query),这样它才知道该如何进行筛选。
六. 知识点总结与补充
1. 关于prompt:目前主流的模型都是以消息列表形式传递用户请求content的,即便使用了模板插值,最终也是使用agent.invoke({'messages': [{'role': 'user', 'content': 'xxx'}]})的形式调用的智能体。可以两种形式结合使用,但要注意{target_content}是否自己已经提前定义好,agent不会凭空取值修改system_prompt,只会以用户消息的形式传入用户query。如果需要修改system_prompt,可以使用middleware中的dynamic_prompt,动态拼接system_prompt,如下:
1 # 例子:餐厅搜索工作流 2 # 看起来"固定",但运行时可能需要根据: 3 # 1. 用户是否是高级会员 → 不同的搜索策略 4 # 2. 用户偏好的菜系 → 不同的输出格式 5 # 3. 多轮对话中的上下文 → 调整搜索范围 6 from langchain.agents.middleware import dynamic_prompt, ModelRequest 7 8 @dynamic_prompt 9 def restaurant_search_prompt(request: ModelRequest) -> str: 10 """看起来固定,但根据运行时上下文调整""" 11 user_level = request.runtime.context.get("user_level", "regular") 12 conversation_length = len(request.messages) 13 14 # 基础 prompt 15 base = "你是餐厅搜索助手。" 16 17 # 根据用户等级调整详细程度 18 if user_level == "vip": 19 base += "为 VIP 用户提供个性化餐厅推荐。" 20 21 # 如果已经搜过多次,添加去重指令 22 if conversation_length > 4: 23 base += "避免重复推荐已搜索过的餐厅。" 24 25 base += """ 26 从名字、人均、评分、位置四个方面整理输出。 27 仅输出 json,不要多余文本。 28 """ 29 return base
2. 关于Agent:langchain定义的agent会自动循环判断是否达成用户目标,是否要调用工具、调用什么工具。但它判断是否达成目标的一般方式为判断当前是否已经不需要再调用工具了(或者说,没有可以调用的工具)。langchain更建议使用一个agent进行调度,其它agent都作为工具来调用。
3. 在实现需求过程中的思考:其实我的第一版代码距离最小限度的能用,只需要改一点点prompt的描述,让模型知道第一步干嘛,第二步干嘛。我的prompt还是有点笼统。所以除了更好地使用框架,还需要优化自己写prompt的能力,一点一点调试。和大模型一起开发,里面会有很多不确定性(或者说弹性),有的时候改prompt也可以,改调用方式也可以,具体的最佳实践还需要结合自己的需求慢慢体会。
浙公网安备 33010602011771号