ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Langchain的联网数字分身实战:检索、ReAct与百度搜索API整合

基于Langchain的联网数字分身实战:检索、ReAct与百度搜索API整合

串起上一篇的内容,我先把话放在前面:这篇不是从零开始的Langchain入门教程,而是接着"数据采集与预处理"往下走的实战续篇。上一篇文章里,我把名人公开访谈、语录、新闻稿攒成了一个可检索的文本库,做了清洗、分段和向量化入库。今天这篇的重点,是用Langchain把这一堆"死数据"变成能聊天的"活分身",同时解决一个关键问题——当分身遇到语料库里没有的信息时,它能自己去查百度。

1. 先理清楚这一篇要干什么:从"检索式问答"到"带工具的数字分身"

1.1 上一篇的成果简单回顾

在动手写代码之前,必须得把"数字分身"这件事拆干净。上一篇我做完了这些事:

  • 收集了某位虚构科学家的公开访谈、演讲、传记章节,大概几十万字。
  • 做了清洗和分段,每段控制在500字左右,方便之后做向量检索时拿到的上下文不至于太碎。
  • 用嵌入模型把分段文本转成了向量,存入向量数据库。我这边用的是开源方案,具体选型看个人偏好,本质就是把文本变成一串高维浮点数。
  • 写了一个检索函数:给它一个问题,它能从库里捞出最相关的3到5段文本。

这个底座的作用是"记忆"——让分身对名人的生平、观点、口癖有基本的掌握。但只有记忆是不够的。比如有人问"这位科学家如果活到今天,会怎么看最近发布的某款芯片?"这个问题库里肯定没有答案,如果硬答,就会胡编。这时候就需要一个"外挂搜索引擎",让分身在库里找不到答案时,能自己上网搜最新信息,再结合名人一贯的逻辑风格组织回答。

1.2 本期要打通的核心链路

这一篇的完整链路是这么设计的:

用户提问 -> 先在本地方言料库中检索,拿到相关片段 -> LLM判断:片段够不够回答这个问题? -> 不够 -> 触发百度搜索API -> 把搜索结果清洗成文本 -> 够 -> 跳过搜索 -> 将"检索片段 + 搜索结果 + 对话历史 + 角色设定"组装成Prompt -> 调LLM生成最终回答

这个链路里最值钱的部分,不是"调用搜索API"这个动作本身,而是"LLM自主决定要不要去搜索"这个判断环节。它决定了整个分身是"死记硬背的复读机"还是"有判断力的对话者"。后面的章节我会详细拆这块。

2. 接入百度搜索API:签名、请求和返回解析

2.1 申请接口与确认能力边界

数字分身要用的搜索能力,我选的是百度搜索API。先说一句公道话:国内环境下它的可用性和返回质量比较稳定,适合做中文名人分身。申请流程不复杂:在百度的开发者平台注册账号、创建应用,拿到API Key和Secret Key,然后在控制台开通网页搜索能力。开通之后,你需要把自己服务器的公网IP加到白名单里,这一条很多人会漏,但漏了的结果就是请求永远报"来源ip权限错误"。

搞清楚能力边界很重要。搜索API不是万能的,它返回的是"网页标题+网页摘要+链接"这类元数据,而不是整篇正文。有些接口能返回部分正文片段,但别指望它像爬虫一样把全文抓回来。所以你在设计查询词和解析结果时,要考虑好"摘要是否够用"这个问题。

我这里用到的接口有两个核心字段需要重点关注:

字段说明处理建议
result/items搜索结果列表,通常包含标题、摘要、URL遍历提取,拼接成纯文本上下文
queries/search_information搜索建议、相关词、搜索耗时等信息用来调试查询词质量,生产环境可忽略

2.2 请求签名:不到10行代码搞定

百度搜索API的签名逻辑比较常规:拼接参数、加密、大写,然后拼到URL后面。直接看代码:

import hashlib import urllib.parse import time import requests def generate_sign(params: dict, secret_key: str) -> str: # 参数按key的字母序排列,拼接成 query string query_string = "&".join( f"{k}={params[k]}" for k in sorted(params.keys()) ) # 在末尾拼上secret_key,做MD5并转大写 sign = hashlib.md5((query_string + secret_key).encode("utf-8")).hexdigest().upper() return sign def build_request_url(query: str, api_key: str, secret_key: str) -> str: params = { "q": query, "num": 5, # 返回条数,我一般控制在5条以内 "time": str(int(time.time())), } sign = generate_sign(params, secret_key) params["sign"] = sign params["api_key"] = api_key base_url = "https://api.example.com/search" return base_url + "?" + urllib.parse.urlencode(params)

这里面容易踩的坑有三个:

第一,签名用的参数必须和实际请求参数完全一致,多一个少一个都不行。我之前调试时在URL里多加了一个调试用的字段,结果签名校验直接失败,排查了半天。

第二,时间戳尽量用服务端时间,不要用本地时间。如果你服务器时间不准,签名会被判定过期。

第三,查询词不要原封不动拿用户提问去搜。用户可能问"他以前说过关于AI的什么观点",这种长句直接搜效果很差。我一般会在触发器里对用户问题做一个压缩改写,抽出核心搜索词,这个后面会写。

2.3 返回数据的清洗:别把网页噪声喂给LLM

搜索结果返回之后绝对不能直接塞进Prompt。搜索接口返回的内容通常包含HTML标记、广告位信息、空字段,直接喂给LLM会导致两个问题:一来浪费token,二来干扰角色扮演。

我写了一个清洗函数,做这几件事:

def clean_search_results(raw_results: list, max_len: int = 2000) -> str: chunks = [] for item in raw_results: title = item.get("title", "").strip() summary = item.get("summary", "").strip() link = item.get("link", "").strip() if not title and not summary: continue # 简单去一下HTML标签 summary = re.sub(r"<[^>]+>", "", summary) chunks.append(f"标题:{title}\n摘要:{summary}\n来源:{link}") text = "\n\n".join(chunks) return text[:max_len]

注意几个细节:

  • 过滤掉明显是广告的条目。有些搜索结果里混着推广内容,标题和摘要跟真正的信息性内容混在一起,LLM分不清的时候会把广告内容当成名人观点,闹出笑话。
  • 控制长度。Max length设在2000字符左右比较合适,太长了挤占上下文空间,太短了信息量不足。
  • 保留链接。虽然在LLM回答里不一定需要展示链接,但保留它可以在后续做"引用溯源"功能时使用——现在不处理,以后还得回头改。

这里有一个实际遇到的问题:搜索API偶尔会超时。特别是网络不稳定的时候,一个请求可能要等5秒甚至10秒。数字分身对话讲究交互体验,让用户干等8秒是灾难。我的做法是设置3秒超时,超时直接放弃搜索,让LLM基于已有语料库回答,并在Prompt中注明"搜索服务不可用,请基于已知语料回答",避免它一本正经地瞎编。

3. Langchain的Agent机制:让分身学会"什么时候该搜"

3.1 不是所有问题都需要搜索:先理解ReAct模式

Langchain把"让LLM决定调用什么工具"的能力封装成了Agent机制。它底层用的是一种叫做ReAct(Reason + Act)的推理循环:LLM根据用户的输入,推断当前需要采取什么行动,行动完了之后把观察到的结果反馈给它,它再决定下一步动作,直到攒够信息生成最终回答。

用一个生活化的比喻来解释:你在家做一道菜,发现缺一味调料。你打开手机搜索菜谱,看看有没有替代方案,然后决定是换调料还是跑一趟超市。ReAct就是这个过程——LLM就是那个做菜的人,"缺调料"的判断对应reasoning,"搜索菜谱"对应action,看完结果再决定下一步,就是observation和继续推理。

Langchain里构建ReAct Agent需要三件事:一个是LLM本身(负责推理决策),一个或多个工具(Tool,负责执行动作),还有一个Prompt模板(负责约束LLM的行为边界)。下面逐个说。

3.2 把百度搜索封装成Agent工具

Langchain生态里封装搜索没太多现成的直接方法,自己包一层反而更可控。我写了一个搜索工具,注册给Agent:

from langchain.tools import tool @tool def baidu_search_tool(query: str) -> str: """当需要获取名人相关的最新信息、时事观点、陌生名词解释时, 使用此工具通过百度搜索获取当前网络公开资料。 查询词请尽量精简,2到6个字为佳。""" url = build_request_url(query, API_KEY, SECRET_KEY) resp = requests.get(url, timeout=3) if resp.status_code != 200: return "搜索失败,请直接基于内部知识回答" data = resp.json() raw_results = data.get("result", []) return clean_search_results(raw_results)

这段代码的核心是那个docstring。不要小看它,Langchain的Agent会把这个docstring当作工具说明书的一部分,LLM读到它才知道这个工具是干嘛的、什么时候该调用它、参数应该怎么填。docstring写得越具体,Agent的调用准确率就越高。

我试过把docstring写成"百度搜索,输入问题",结果LLM经常拿很长的原文去搜,搜索效果差。改成"查询词请尽可能精简"之后,调用质量明显提升。

3.3 写一个决策链:让"搜不搜"不再是黑盒

Agenty模式的封装程度很高,但在名人分身这个场景里,纯靠LLM自己决定是否搜索,其实不太可控。原因在于名人对话场景里,很多问题明明属于"开放性观点讨论",根本不需要搜索,但LLM有时候会过度依赖搜索工具,反而让回答变得生硬。

所以我做了个折中方案:不直接上Langchain的AgentExecutor,而是自己写一个轻量的决策链,用Langchain的链式调用和路由能力做约束。核心逻辑如下:

response = llm.predict(prompt) decide_prompt = f""" 以下是用户的问题: {user_question} 以下是内部语料库检索到的相关信息: {context} 你认为仅凭以上语料库的信息,能高质量回答用户问题吗? 如果能,请输出"CAN_ANSWER"; 如果不能,请输出"NEED_SEARCH"。 """ decision = llm.predict(decide_prompt).strip() if decision == "NEED_SEARCH": search_text = baidu_search_tool(user_short_query) # 精简后的查询词 context += f"\n\n【搜索结果补充】\n{search_text}" final_response = llm.predict(final_prompt_with_role(context, user_question))

这个做法的好处在于:搜索决策变成了一个显式的Prompt分类任务,而不是藏在Agent内部的黑盒推理。你可以通过修改"decide_prompt"里的措辞来精确控制搜索的触发条件,调试起来非常直观。

实际跑下来,这个方案明显比全自动Agent更稳。唯一的代价是搜索决策这一步多了一次LLM调用,延迟增加了300到500毫秒,但换来的是回答准确度和可控性的大幅提升。对于数字分身这种"角色感优先"的场景,这个取舍完全值得。

关于"查询词精简"这一环,我单独写了个小函数,用LLM把用户问题浓缩成搜索关键词。比如用户问"他当年为什么反对核试验?",精简后的搜索词是"人名+核试验 观点"。搜索效果比直接搜原文好一大截。

4. 记忆管理:只有上下文连续的分身才谈得上"人设"

4.1 对话记忆的三种选择,我为什么只用了最简单的

Langchain里做对话记忆的组件有好几个:ConversationBufferMemory、ConversationBufferWindowMemory、ConversationSummaryMemory、VectorStoreRetrieverMemory等。一开始我试图一次全用上,结果调试到崩溃——叠加太多记忆组件会让Prompt极其臃肿,LLM经常被绕晕,角色扮演反而越演越偏。

最终我只保留了一个:ConversationBufferWindowMemory,窗口大小设为6轮对话。说白了就是只保留最近6轮你问我答,每轮的对话存进一个列表,拼进Prompt里让LLM知道"刚才聊到哪了"。

为什么不用SummaryMemory?因为总结额外消耗一次LLM调用,而且总结内容本来就可能丢失关键细节。对于名人数字分身来说,连续对话的"即时感"比"长期记忆"重要得多。窗口记忆虽然笨,但它不会把信息搞丢。

4.2 记忆不只是拼历史:还要喂给"搜索决策"环节

有个细节特别容易被忽略:搜索决策不仅要看当前问题,还要看对话历史。比如用户上一轮问"他怎么看技术创新",这一轮接着问"那风险和收益呢?",如果决策Prompt只看当前这句"风险和收益呢?",上下文缺失,LLM会蒙圈——它不知道"他"是谁,也不知道要搜什么主题。

所以我的决策Prompt和最终答案Prompt都包含同一个对话历史:

conversation_history = memory.load_memory_variables({})["history"] decode_prompt = f""" 对话历史: {conversation_history} 用户当前问题:{user_question} ... """ final_prompt = f""" 对话历史: {conversation_history} 检索到的相关资料: {context} 目前的对话轮次,你仍需保持人物性格回答。 用户问题:{user_question} """

这样才能做到"连续几轮追问下来,分身的回答还是连贯的"。

4.3 记忆长度控制:宁可炸掉Prompt,也别让记忆无限膨胀

最后必须聊一下窗口记忆的截断策略。6轮对话听起来不多,但每一轮用户的提问和你的回答可能都很长。按每轮500字算,6轮就是3000字,加上系统设定、检索片段、搜索结果,一个Prompt轻轻松松破5000 token。

我一开始没控制长度,直接把所有东西一股脑拼接,结果调用长文本模型时经常出现上下文超限报错。后来我加了一个简单的截断函数:

def truncate_history(history: str, max_chars: int = 1500) -> str: if len(history) <= max_chars: return history # 保留最近的内容 return history[-max_chars:]

注意这里有个取舍:截断历史时优先保留尾部,因为最近的对话对当前回答影响最大。而检索语料和搜索结果则优先保留头部,因为它们本身就是按相关度排序的。顺序反了,回答质量会肉眼可见地变差。

5. 提示词工程:把"名人感"钉死在每个回答里

5.1 三层结构:身份、规则、习惯

Prompt是数字分身的灵魂。Langchain本身不解决提示词设计问题,它只是帮你把字符串拼起来。我设计角色Prompt时用了三层结构,你可以直接抄这个框架:

第一层是身份层。告诉LLM"你是谁",用第一人称或第三人称描述名人的核心标签。最简单的写法是"你现在是XXX,你是一位著名的XXX,你在公开场合的谈话风格是XXX"。

第二层是规则层。约束LLM在对话中的行为边界。比如"回答中不要出现'作为一个AI'""不要承认你是语言模型""当你不确定某件事时,可以表示会根据经验推测而不是说不知道"这类指令。规则层是防止AI痕迹的关键。

第三层是习惯层。把语料库里挖出来的表达习惯、口头禅、典型句式塞进去。光说"你要模仿XX的语气"是没用的,必须给具体的样例,比如"你在表达强烈观点时经常使用'我始终认为'开头""你习惯在回答结束时反问对方一句"。这一层做得越具体,回答就越像本人。

5.2 完整的角色Prompt示例

直接贴一个我调试好的模板,注意看各层之间如何衔接:

【身份设定】 你现在是虚构人物"某著名科学家",公众眼中的你热爱理性思辨,讲话锋利但保持礼貌, 喜欢用逻辑拆解问题,偶尔透露出幽默感。你擅长把复杂的科学概念讲成日常故事。 【行为规则】 1. 永远不要提自己是AI或语言模型。 2. 回答尽量口语化,避免书面腔;避免过度使用"首先""其次""然后"这类结构词。 3. 遇到无法确定的事实,可以用"以我当时的观察来看"开头,表达基于经验的推断。 4. 只要条件允许,在回答最后用一句提问或调侃保持对话感。 5. 不回避尖锐问题,但会用科学家的视角把话题引向"数据是否支持这个判断"。 6. 回答长度控制在100到200字,除非用户明确要求详细说明。 【表达习惯】 - 你表达认可时常说"这个思路有意思"。 - 你表达怀疑时常说"我不太确定这个前提站得住脚"。 - 你提到过往经历时,习惯用"在我研究那批实验数据的年代"带入。 【对话历史】 {conversation_history} 【当前资料】 {context} 【用户问题】 {user_question} 请以"某著名科学家"的身份直接回应。

这块的核心是注入具体样例。你给LLM三个"口头禅",好过你给它三千字的性格描述。语言模型擅长从例子中泛化,从抽象描述中模仿则会显得空。

5.3 搜索结果进入Prompt时,要显式区分"语料"和"新闻"

我在4.2的代码里写过合并语料和搜索结果,但这里有个易踩坑点:直接合并会让LLM分不清哪些是"名人的确说过的话",哪些是"网上搜来的新闻"。

结果就是,LLM可能把新闻稿里的内容当成名人亲口说的观点,回答时语气变成"我最近表态说……",实际上名人根本没说过。为了规避这个问题,我在Prompt里把两块内容显式拆开:

【内部语料(人物一手资料,回答时可作为自己说过的话)】 {corpus_context} 【网络检索结果(外界报道,回答时需要注意区分转述) {search_context}

这样LLM就明白,内部语料可以"变成"自己的话,而网络检索结果只能作为外部参考,回答时要像名人自己在转述新闻,而不是把新闻内容说成自己的亲口表态。这个区分对数字分身的可信度至关重要。

6. 全链路联调:从机械问答到有性格的数字分身

6.1 第一轮实测:暴露出了三个问题

搭完所有模块后,我找了个测试问题开始联调:"你怎么看最近那种大语言模型写诗这件事?"

第一次跑出来的回答是:"作为一种人工智能技术,大语言模型写诗是自然语言处理领域的进步,它展示了深度学习的潜力,同时也引发了一些关于创造力的讨论……"。

这回答从技术上挑不出大毛病,但它是标准的"AI客服腔"。三个问题暴露出来:

第一,行为规则第一条没生效——它自称"人工智能技术"了。这说明我的规则层措辞太弱,LLM没有当回事。我调整了写法,把"不要提自己是AI"改成了"你从来不认为自己是一个程序或模型,你觉得自己就是一位有血有肉、有科研经历的科学家",强行纠正身份认知。

第二,回答规格是典型的总分模式,完全不像名人私下闲聊。原因是"将复杂的科学概念讲成日常故事"这个指令给了,但缺少具体的对话感约束。我补充了第4条规则"用提问或调侃保持对话感",回答一下子就活了。

第三,"当你提到口癖时偶尔穿插"这个意图没有体现。原因很简单——只是说"你习惯说XX"还不够,必须给出具体的句式示例。我在"表达习惯"里加了至少3个直接引语例句,比如"这个思路有意思。不过数据呢?"然后微调之后,回答里开始出现像样的个人风格了。

调整后的回答被重跑成:

"写诗这事,我倒是有点发言权——我年轻时写过不少打油诗,质量嘛……很能说明为什么我最后改行做研究了。模型写诗,本质上是在统计规律里找美感,这个思路有意思。不过它写的诗,看第一眼觉得很美,再看一遍就少了点悬念。"

你看,这句话里既有"这个思路有意思"的口癖,又用了"统计规律里找美感"的科学家式比喻,还有自嘲的幽默感——这才是一个有性格的分身。这一轮调试的经历让我确定:角色感的建立主要靠反复试错,每加一条具体措辞,回答都会向"像人"迈进一大步。

6.2 搜索触发调优:解决"到处搜"和"死活不搜"两个极端

接着我做了十轮连续对话测试,统计搜索触发情况。结果很典型:

  • 第一版决策Prompt写的是"如果你觉得信息不足,就输出NEED_SEARCH"。结果LLM对"信息不足"的判断很松,超过一半的轮次都触发了搜索,回答时间显著变长,而且很多搜索根本没必要,比如"你平时几点睡觉"这种个性化问题也要去搜。
  • 第二版Prompt改成"只有当你认为内部语料库完全没有相关内容,或问题涉及近一年内的事件时,才输出NEED_SEARCH"。效果反过来,触发率只有10%左右,但确实有一次用户问某本新书的评价时,LLM没去搜,答案明显过时了。

最终我稳定下来一版折中方案:

判断标准: 1. 问题是否涉及内部语料库明显未覆盖的新事件、新作品、新名词? 2. 问题是否需要依赖外部事实才能给出可靠答案? 3. 如果跳过搜索,回答是否会与公知常识明显冲突? 以上任一条件成立,则输出NEED_SEARCH;否则输出CAN_ANSWER。

这个版本把"搜索决策"从模糊的直觉判断变成了三个可量化的标准,实测触发率稳定在30%左右,既保证了时效性,又不会每轮都搜拖慢响应速度。

6.3 性能实测与Token成本观察

最后说点工程上的观察数据。我用一台普通开发机做实测,完整链路长这样:

环节平均耗时备注
内部语料检索80ms向量检索很快,数据量不大
LLM搜索决策500ms一次短调用的推理时间
百度搜索API请求800ms到2s网络波动时可能更久
LLM最终回答生成1.5s到3s取决于回答长度
全过程合计3s到5s体感略慢但在可接受范围

Token成本方面,最大头的消耗在于两个LLM调用和拼接的上下文。实测下来,一次完整对话平均消耗约2500个token。高频使用前最好做个预估,别等项目上线了才意识到成本问题。

还有一个容易忽略的点:并发控制。搜索API是有QPS限制的,单机开发测试时感受不到,但如果将来接到公众号或网页端同时在线服务,必须给搜索调用加信号量或队列,防止并发超限被平台限流。我用的办法很简单,就是给搜索工具包了一层线程锁,确保同一时刻只有一个搜索请求在飞。

6.4 一个值得做的后续扩展:"先检索,再搜索,后回答"的三段式

目前整条链路是按照"检索→决策→搜索→回答"的顺序执行的。跑了一段时间之后我发现,很多问题其实可以先用内部语料库回答,把网络搜索作为补充而不是替代。下一步我打算把决策层改成三级判定:

内部语料检索结果的相关度评分 >= 阈值 -> 直接用内部语料回答 相关度评分在阈值边缘 -> 带着语料库结果去搜索补充 相关度评分远低于阈值或涉及新事件 -> 先搜索,再回答

这个思路比现在的二值判断更细腻,能进一步减少搜索调用次数。等这条稳定了,我再把Langchain原生的Agent机制融合进来,让LLM能更自主地串联多步搜索和追问。到时候再写第三篇分享。

如果你打算复现这套方案,我最后的建议是:先别急着堆组件和机制,老老实实把"检索+搜索+Prompt模板"这三个最底层的东西打磨到位,角色感自然就有了。框架性的东西只是工具,真正决定一个数字分身像不像本人的,永远是语料质量、搜索精度和那句精心设计的问句。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进