最近我基于 LangChain 做了一件挺有意思的事:让一个 Agent 读取产品测试用例,自动生成可执行的 Playwright UI 自动化脚本。整个项目跑下来,我最大的感受是——LangChain 这套生态早就不是当年那个只会“拼 Prompt、调接口”的玩具了,RAG、Agent、工作流编排这些模块组合起来,已经完全能支撑一个真实的生产级场景。
这篇内容不是概念科普,而是一份实战记录。我会从零开始拆这个项目的完整链路:框架怎么选型、本地知识库怎么搭、Agent 怎么设计、Tools 怎么写、LangGraph 怎么编排,最后把我在安装配置、检索去重、模型能力对比这些地方踩过的坑也一并列出来。如果你已经把 LangChain 入门资料翻过一遍,准备动手做点真实项目,那这篇文章应该能帮你省下不少试错时间。
1. 项目设计思路:别急着写代码,先选对框架
1.1 这个 Agent 到底要解决什么问题
需求方手里有几百条测试用例,格式比较杂,有 Excel 表格,也有 Markdown 文档。以前的做法是测试同学逐条阅读,再手动用 Playwright 写脚本,一条用例平均要花二三十分钟,枯燥而且容易漏断言。我们想做的是:把测试用例文件丢给 Agent,它自动生成一套结构完整的 UI 自动化脚本,测试同学只需要审核和微调。
但要直接“文件进、脚本出”,其实没那么简单。真实项目里至少有四个坎:第一,测试用例的描述是非结构化的,很多步骤隐含在业务背景里;第二,历史脚本里有很多约定俗成的写法,比如选择器规范、等待策略、断言风格,Agent 不可能凭空知道;第三,生成的脚本必须能通过语法检查和基础逻辑校验,不然交出去就是垃圾;第四,全自动生成的脚本没人敢直接上线,必须有人工审核介入。
所以这本质上不是一个“调用大模型写代码”的项目,而是一个“把已有知识沉淀、检索、生成、校验、人工确认串起来”的系统工程。这也是我坚持要用 LangChain 生态而不是直接裸调 API 的原因:我需要可复用的组件,也需要可控的流程。
1.2 LangChain 与 LangGraph:我的取舍标准
很多人一上来就纠结 LangChain 和 LangGraph 到底用哪个,其实两者根本不是竞争关系,而是不同层级的工具。LangChain 更像一个工具集,负责对接模型、封装 Prompt、管理向量库、定义 Tool;LangGraph 则是一个流程编排框架,它把任务状态显式建模成一张图,支持条件分支、循环重试、人工介入。
打个比方:LangChain 是厨房里的锅碗瓢盆和半成品食材,LangGraph 是后厨的动线和规矩。你当然可以只用锅碗瓢盆把菜炒出来,但一旦工序多了、时不时要做熟度检查、失败了要回锅,你就需要一套明确的出餐流程。
| 对比维度 | LangChain | LangGraph |
|---|---|---|
| 定位 | 组件集成与工具库 | 状态化流程编排 |
| 适合场景 | 固定链路、快速原型 | 分支循环、Agent 多轮决策 |
| 状态管理 | 隐式传参 | 显式 State 对象 |
| 可控性 | 中等 | 高,可随时人工介入 |
| 学习曲线 | 较平缓 | 稍陡峭,但值得 |
这个项目最终选了“LangChain 做零件,LangGraph 做骨架”的组合。具体分工是:用 LangChain 的 Tool 机制封装文件读取和脚本校验能力,用它的 VectorStore 接口连接 Chroma,用它的 PromptTemplate 管理提示词;主线流程则用 LangGraph 的 StateGraph 来编排,状态里保存测试用例内容、生成的脚本、校验结果、重试次数。想改逻辑的时候,改图的节点比改一堆 if-else 的链式调用要舒服得多。
2. 本地知识库搭建:Ollama + Chroma 跑通 RAG 全流程
2.1 conda 环境准备与版本锁定
先说环境。我习惯用 conda 管理 Python 环境,版本选了 Python 3.11。不用 3.12 是因为当时不少基础库的预编译 wheel 还不全,在这种依赖众多的项目里,能少踩一个坑是一个。
conda create -n langchain-agent python=3.11 -y conda activate langchain-agent pip install langchain langgraph langchain-community langchain-openai langchain-ollama langchain-chroma chromadb这里必须提醒一句:LangChain 的版本演化非常激进,0.1 时代的 API 和 1.0 时代的 API 差别很大。比如早期文档里常见的from langchain.llms import Ollama现在已经迁到了langchain-ollama包里,直接照抄老代码会报模块不存在的错误。我建议安装完第一时间执行pip list | grep langchain,看清主版本,并且把版本号写进requirements.txt,不要在开发中途随手pip install -U langchain。
如果你是要给公司做交付,还可以进一步考虑把依赖全部用 pip-tools 或类似方案锁死。这种项目最怕的不是代码难写,而是“昨天还能跑,今天升级后就不能跑了”的版本漂移。
2.2 模型与 Embedding 的选型细节
本地模型我用的是 Ollama 拉取的 qwen2.5:7b,主要跑在办公内网,不涉及外部接口服务,还省了数据合规的麻烦。Embedding 模型用的是 nomic-embed-text,考虑到后续要检索中文测试文档,如果你想在同等资源下追求更好的中文语义效果,可以考虑改用 bge-m3,或者 mxbai-embed-large。注意 Embedding 选型和主 LLM 是两回事,很多新手在“本地化”上犯的第一个错误就是只把聊天模型本地化,Embedding 还在用在线接口,导致向量入库和检索阶段就产生了隐私瓶颈。
ollama pull qwen2.5:7b ollama pull nomic-embed-text拉取耗时取决于网络,但本地推理的部署方式几乎没有额外成本。我在 64GB 内存的 Mac 上实测,qwen2.5:7b 跑轻量代码生成完全不卡,如果用 CPU 推理的 Windows 机器,建议把模型降到 qwen2.5:3b 或使用带量化参数的模型,牺牲一点效果换可用性。
2.3 Chroma 向量库与切分策略
Chroma 是最适合这类单体项目的向量库,它不需要单独起服务,代码里直接持久化到本地目录就行。实现里我用langchain-chroma的Chroma.from_documents把切分好的文档写入磁盘,后续查询走as_retriever接口。这里的关键决策是切分策略。
文档切分不是简单按固定长度切,中文场景尤其要注意标点符号。我使用RecursiveCharacterTextSplitter并显式指定了中文句读的切分点,避免把一句话硬生生截成两段导致语义破碎;chunk_size 设为 512,chunk_overlap 设为 64。为什么是这组参数?512 个字符对大多数 QA 场景几乎是安全上限,太长会让召回结果包含过多噪音,太长则上下文碎片化;64 的 overlap 则保证了句子边界的连续性。如果面对的是测试用例这类强结构化文档,更好的方案是先用自定义解析器按表格行或 Markdown 标题来切,而不是无脑递归切。
2.4 MultiVectorRetriever 的高级召回用法
在这个项目里,我处理历史脚本库时用到了MultiVectorRetriever。这个组件解决的问题很典型:一个长文档的某个局部片段,语义上可能和用户问题关联不大,导致召回失败。但如果你为整个文档生成一段摘要、并用摘要向量去召回,再把同文档的原始片段取出来供大模型阅读,命中率就会有明显提升。
简单说,MultiVectorRetriever 维护了两套存储:一套是摘要向量库(负责“找得对”),一套是原始文档存储(负责“读得全”)。代码结构类似这样:
from langchain.retrievers.multi_vector import MultiVectorRetriever from langchain.storage import InMemoryStore from langchain_chroma import Chroma vectorstore = Chroma(collection_name="summary", embedding_function=embeddings) docstore = InMemoryStore() retriever = MultiVectorRetriever( vectorstore=vectorstore, docstore=docstore, id_key="doc_id", ) # 写入时:先为每个大块生成摘要,摘要进 vectorstore,原块进 docstore # 查询时:summary 向量召回后,通过 doc_id 映射取回原块实际体验下来,对于那种“几百行历史脚本混在一个文件里”的场景,这个模式比纯 chunk 向量召回的表现稳定得多。代价是你得多跑一轮摘要生成,但换来的是检索质量的大幅提升,值得。
2.5 完整 RAG 查询链路
RAG 流程在项目里的作用,是给 Agent 提供“历史相似脚本”的参考。整体链路是:测试用例文件进入后,先拆成结构化描述,然后从历史脚本库检索相似片段,把检索结果喂给生成模型的 Prompt,最后把生成的脚本写入状态。
核心查询链路的代码骨架如下:
from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough prompt = ChatPromptTemplate.from_messages([ ("system", "你是资深测试开发,请参考历史脚本风格生成 Playwright 代码。"), ("user", "历史参考:\n{context}\n\n测试用例:{question}"), ]) def format_docs(docs): return "\n\n---\n\n".join(d.text for d in docs) qa_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() )你不需要把 RAG 直接挂进生成的上下文里,而是把它封装成一个“检索历史脚本”的工具函数,让 Agent 在需要时自行调用。这么做的好处是模型可以先看测试用例、判断是否需要参考历史脚本、再决定检索的关键词。这比“不管三七二十一先检索再生成”要灵活得多,也更符合真实的开发节奏。
3. Agent 落地:从测试用例自动生成 Playwright 脚本
3.1 整体流程怎么设计
Agent 的主流程我设计成了五个阶段:加载用例、检索参考、生成脚本、校验修正、人工确认。这五个阶段不是简单的顺序执行,而是带反馈回路的图。生成的脚本如果校验出语法问题,就回到生成阶段重写;人工审核不通过,也会回到生成阶段并附带修改意见。
这里有一个经验:不要在系统里一次性塞太多自由发挥空间。Agent 的任务粒度越细,结果越可控。我先让 Agent 做“阅读理解”把用例拆成步骤列表,再专门做“代码生成”,最后做“自我校验”。这看起来多了一个步骤,实际却大幅降低了生成脚本的幻觉概率——因为模型一旦把动作步骤拆得足够清楚,代码生成就不容易漏掉中间步骤。
3.2 Tools:把文件和校验能力暴露给模型
LangChain 的 Tool 机制是 Agent 能力的外延。我们这里给模型注册了三个核心工具:一个是读取并解析测试用例文件,一个是检索历史脚本,一个是校验生成脚本的语法和关键结构。定义方式用@tool装饰器即可:
from langchain.tools import tool @tool def load_test_case(file_path: str) -> str: """读取测试用例文件,支持 xlsx/md/txt,返回格式化的步骤列表。""" # 按扩展名分发解析逻辑,xlsx 用 openpyxl 逐行读取 return formatted_cases @tool def search_example_scripts(description: str) -> list[str]: """根据步骤描述检索历史 Playwright 脚本片段。""" docs = retriever.invoke(description, k=3) return [d.page_content for d in docs] @tool def check_playwright_script(script: str) -> str: """对脚本做 ast 语法检查和关键 API 检查,返回问题和修复建议。""" issues = [] tree = ast.parse(script) # 检查 page.locator、expect 等关键调用是否出现 return "\n".join(issues) if issues else "通过"工具返回信息一定要结构化。很多 Agent 项目之所以不稳定,是因为工具返回一堆无格式文本,模型根本不知道哪些是要害。像check_playwright_script就应该返回“问题行号 + 问题类型 + 修复建议”,而不是笼统的一句“脚本有问题”。
3.3 LangGraph 编排:失败重试与人工介入
整个主流程我写在StateGraph里。状态对象用TypedDict定义,保存测试用例内容、参考脚本、最终脚本、校验反馈、尝试次数这些字段。节点之间通过状态传递数据,条件边负责决定下一步往哪走。
from typing import TypedDict from langgraph.graph import StateGraph, START, END class AgentState(TypedDict): test_case: str examples: str script: str validation: str attempts: int def load_node(state: AgentState): cases = load_test_case.invoke(state["test_case"]) return {"test_case": cases} def retriever_node(state: AgentState): examples = search_example_scripts.invoke(state["test_case"]) return {"examples": examples} def generate_node(state: AgentState): prompt_text = gen_prompt.format(cases=state["test_case"], examples=state["examples"]) script = llm.invoke(prompt_text) return {"script": script, "attempts": state.get("attempts", 0) + 1} def validate_node(state: AgentState): result = check_playwright_script.invoke(state["script"]) return {"validation": result} graph = StateGraph(AgentState) graph.add_node("load", load_node) graph.add_node("retrieve", retriever_node) graph.add_node("generate", generate_node) graph.add_node("validate", validate_node) graph.add_edge(START, "load") graph.add_edge("load", "retrieve") graph.add_edge("retrieve", "generate") graph.add_edge("generate", "validate") graph.add_conditional_edges( "validate", lambda s: "generate" if "未通过" in s["validation"] and s["attempts"] < 3 else "end", {"generate": "generate", "end": END} )人工确认节点我再单独用一个interrupt机制挂进去,让脚本在交付前停在审核状态。这种设计在真实场景里很实用:你不需要让 Agent 一次做到 100% 完美,它能做到 80%,剩下 20% 交给人在规定节点上把关,已经很能节省人力。我强烈建议任何准备上生成式自动化项目的人,都在流程图里预留一个人工闸口,否则项目上线后的容错率会非常堪忧。
3.4 实测一下它生成的脚本长什么样
用一条简单的登录用例来测试,输入是“打开登录页,输入用户名 admin,输入密码 123456,点击登录,断言跳转到首页板标题”,系统最终输出的核心代码如下:
from playwright.sync_api import sync_playwright, expect def test_login(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("http://localhost:3000/login") page.locator("#username").fill("admin") page.locator("#password").fill("123456") page.locator("button[type='submit']").click() expect(page).to_have_title("首页") browser.close()当然,真实测试用例不会这么干净,所以我在 Prompt 层面做了几条硬约束:必须使用项目现有的选择器约定、必须显式等待关键元素、断言必须覆盖用例给出的验收点。这套约束写进系统提示词后,代码风格基本稳定,人工审核成本明显下降。
4. 常见问题与排查速查
4.1 安装与版本兼容
实际踩坑中很多问题不是代码逻辑问题,而是安装和版本。先说 conda 的选择:用 conda 创建独立环境是必须的,别直接装到 base 环境。Python 版本优先选 3.11,装 LangChain、LangGraph、Chroma 以及 Playwright 插件时兼容性都很好。如果发现某个包安装时卡在编译环节,多半是 Python 版本过高,conda 换版本重来是最快的办法。
常见的错误还有两个:一个是langchain_community.llms.Ollama和langchain_ollama.OllamaLLM混用,另一个是langchain-openai和langchain_ollama的依赖冲突。我的建议是先在虚拟环境里pip install全量依赖并跑通一个最小 demo,再把版本号记录在案,再开始写业务逻辑。这个顺序不能省。
4.2 RRF 合并检索的“去重缺陷”
检索融合时,很多项目会采用 RRF(Reciprocal Rank Fusion),LangChain 的 EnsembleRetriever 和 Java 生态的 LangChain4j 都有对应的实现。但我发现默认实现存在一个容易忽略的缺陷:去重逻辑不完整,多个检索器召回同一文档的不同 chunk 时,会被当作不同文档重复计算得分,导致结果排序被重复片段抬高。
这个问题在混合检索(BM25 加向量检索)场景下尤其明显。比如同一段历史脚本被摘要向量召回了一次、又被原文 chunk 召回了一次,RRF 默认会把它算成两个结果,两个分相加后排名可能压过了真正匹配但只被召回的文档。修复思路是在融合前按文档 ID 或 metadata 去重;如果你用的是 Chroma 自带检索器,也可以尽量在写入阶段就统一好source字段,再在组合前做聚合。这个坑在官方文档里没有写清楚,属于做多了自然能发现的类型。
4.3 DeepAgents 做到哪一步了,和 Claude 差在哪
很多人会拿 LangChain 出的 DeepAgents 和 Claude 等闭源模型的 Agent 能力做对比。我自己的体会是,这类自动规划型 Agent 在前半程任务拆解上已经做得不错,能够把长任务切成若干带依赖关系的子任务,并在失败时自主修正。但在需要深度理解真实 UI 状态、处理模糊需求和跨长上下文的场景里,它和成熟的闭源模型确实还有肉眼可见的差距,主要体现在复杂上下文保持和工具异常恢复的稳定性上。
实践中我发现一个规律:差距是可以被工程弥补的。把工具返回格式做得足够干净,把任务拆得足够小,把校验节点做得足够严格,开源模型的最终表现会非常接近闭源模型,甚至在某些受限任务上更稳定。所以我的建议是别盲目追求“最强模型”,先在流程和工具上打磨,性价比更高。
4.4 LangChain 真的过时了吗
这个话题在开发者社区隔几个月就吵一轮。我的判断是:LangChain 没有过时,但它的定位变了。早期它靠一揽子抽象吸引了大量用户,现在很多人转向 LangGraph、LlamaIndex 或者直接用框架原生能力,是因为任务变得更复杂、对可控性的要求更高。LangChain 现在更像是连接各类组件和工具的“胶水层”,尤其在企业异构环境里,它提供的 Tool 机制、Prompt 管理、向量库适配仍然有很强的实用价值。
如果你是为了找工作面试准备,我建议把重心放在“理解 Agent 和 Workflow 的设计本质上是什么”,而不是死记 API。面试官问到 LangChain 时,真正有效的能力是讲清楚一个实际业务需求怎么转化为状态图、检索和工具调用。把这个逻辑讲透,比背十个 API 都管用。
最后分享一个我自己的心得:用 LangChain 这类框架做 Agent,最大的风险不是框架不稳定,而是开发者对流程不够敬畏。把任务拆碎、把工具返回结构化、把人放进审核链路,这三件事做到位,项目基本不会出大乱子。如果你也打算跑一个类似的“自动生成测试脚本”的项目,建议从最小闭环开始:先跑通一条用例的生成与校验,再逐步扩大用例集,你会看到这套系统的潜力。