【LangGraph实战】《LangGraph实战》_248.[第12章 实战进阶] 构建Agent的单元测试与集成测试体系

你的LangGraph Agent本地跑得贼溜,一上线就“智障”?别让“靠运气编程”毁了你的项目!今天咱们不聊虚的,直接动手搭建一套Agent专属的单元测试与集成测试体系,让Bug无处遁形,让上线不再“开盲盒”。
文章目录:
- 一、认知破局:Agent测试不是“拍脑袋”
- 二、单元测试:给每个Graph节点做“全身体检”
- 三、集成测试:跑通Agent的“全链路马拉松”
- 四、Mock策略:驯服LLM这个“薛定谔的猫”
- 五、断言设计:在非确定性里寻找确定性
- 六、工程闭环:把测试铸成CI/CD的护城河
嗨,大家好呀,我是你的老朋友精通代码大仙。接下来我们一起学习 《LangChain核心技术与LLM项目实践》
俗话说得好,“写代码不写测试,就像盖房子不搭脚手架——底层没稳住,上层迟早塌”。你有没有发现,咱们搞LangGraph Agent开发的时候,特别容易陷入一种“玄学编程”的状态?本地Jupyter里跑那条Happy Path,顺畅得跟德芙一样丝滑,你就觉得“稳了,上线!”。结果一到生产环境,用户稍微拐个弯提问,Agent要么开始无限循环,要么工具调了个寂寞,要么LLM给你整出一段“意识流”回复,直接把你CPU干烧。
为啥会这样?说白了,Agent不是传统的CRUD,它身体里住着LLM这个“薛定谔的猫”,还有状态机、工具调用、条件路由一大堆复杂编排。你拿以前测Spring Boot或者Django那套硬往上套,就像拿体温计量血压——工具本身就不对。所以啊,今天这篇,咱们就专治这个“Agent测试焦虑症”,把这层窗户纸彻底捅破!
一、认知破局:Agent测试不是“拍脑袋”
很多老铁刚从传统后端转过来,思维还没切换。传统软件输入确定、逻辑确定、输出确定,1+1必须等于2。但LangGraph Agent是啥?它是一个“活”的系统。用户的同一句话,LLM可能有三种不同理解;工具返回的数据格式,可能跟你的Prompt预期差了十万八千里;状态(State)在节点之间传来传去,稍不留神就丢字段。
所以,Agent测试的第一课,就是先打碎你的旧认知。咱们要测的不是“某行代码有没有执行”,而是“这个Agent在面临不确定输入时,行为是不是在可控范围内”。
你是不是也这样干过?写出来的测试用例长这样:
def test_agent():
result = agent.invoke({"input": "帮我订张机票"})
assert result == "好的,请告诉我您的目的地"
结果呢?下次LLM心情不好,回了句“没问题,请问您想从哪里出发?”,测试直接爆红。更惨的是,有人直接在测试里调用真实的OpenAI API,一次CI跑下来,账单比他的工资还高。还有老铁把Agent当成黑盒,只测最终输出,中间哪个节点挂了、哪条边走错了,完全不知道,调试全靠print和猜。最隐蔽的误区是,很多人把LangGraph当成纯LLM应用来测,却忘了它本质是一个有向状态图(StateGraph)。你不测状态的流转,就像不测数据库的事务提交——表面风平浪静,底下数据已经烂透了。
咱们得把测试分层。Agent测试的金字塔,底座是“状态与行为断言”,中间是“节点隔离测试”,顶端才是“端到端验证”。
对LLM层的测试,核心思想是“测意图,不测台词”。比如:
def test_agent_response_semantic():
result = agent.invoke({"input": "帮我订张机票"})
content = result["messages"][-1].content
assert any(k in content for k in ["目的地", "出发地", "时间"])
assert "抱歉" not in content # 确保没有拒绝服务
看到没?我不关心它具体说了啥,我只关心它有没有进入“收集订票信息”的状态,有没有漏掉关键动作。另外,状态机(State)才是LangGraph的灵魂,你的断言要盯着state里的字段变化,比如state["current_step"]是不是从"understand"变成了"collect_info"。
测Agent,测的是“行为”而非“背台词”,盯的是“状态”而非“运气”。认知转过来了,后面的路才好走。
二、单元测试:给每个Graph节点做“全身体检”
LangGraph的Graph是由一个个Node(节点)和Edge(边)拼起来的。单元测试,就是要把这些节点从图里“抠”出来,单独体检。你想啊,一个Agent可能有8个节点,如果只有在跑全流程时才发现某个节点挂了,那定位成本有多高?单元测试就是让你秒级定位到“是哪个原子出了问题”。
新手的经典操作是啥?直接把整个app.invoke()当成单元测试。还有老铁知道要测节点,但不知道LangGraph的节点函数签名长啥样。比如一个节点函数接收state: AgentState和config: RunnableConfig,他传了一个裸字典{"query": "天气"}进去,结果代码里一访问state["messages"][-1],直接报KeyError。
再比如,节点里嵌套了工具调用:
def search_node(state):
query = state["messages"][-1].content
result = real_search_api(query) # 真实的搜索API
return {"messages": [ToolMessage(content=result, name="search")]}
你单元测试跑一下,要么网络超时,要么API限额了,测试红一片。更坑的是,有些节点的副作用是会修改外部数据库,你拿生产环境的数据测单元测试?老板看见账单能把你祭天。
单元测试的核心就俩字:隔离。把节点当纯函数测。
第一步,补齐State。LangGraph的State通常是TypedDict或Pydantic BaseModel,你测试里必须构造一个合法的、完整的State。别偷懒。
第二步,依赖注入。别把工具写死在节点函数里,传进来!
def search_node(state, search_tool=None):
if search_tool is None:
search_tool = real_search_api
query = state["messages"][-1].content
result = search_tool(query)
return {"messages": [ToolMessage(content=result, name="search")]}
这样测试时就可以轻松注入Mock:
from unittest.mock import Mock
from langchain_core.messages import HumanMessage, ToolMessage
def test_search_node():
# 构造一个合法的AgentState
state = {
"messages": [
HumanMessage(content="北京今天天气怎么样")
]
}
mock_search = Mock(return_value="晴天,25度")
result = search_node(state, search_tool=mock_search)
# 断言:验证返回了ToolMessage
assert len(result["messages"]) == 1
assert result["messages"][0].name == "search"
assert "晴天" in result["messages"][0].content
# 验证Mock被正确调用
mock_search.assert_called_once_with("北京今天天气怎么样")
看到没?这样测,快、稳、不花钱。而且你明确知道,这个节点在接收到特定输入时,会输出特定结构,并且会正确调用外部依赖。
一个小技巧,建议你写一个build_test_state的helper函数,封装那些重复构造State的样板代码。比如messages、next_node、context这些字段,每次都手写太烦了。复用fixture,测试写得才爽。
节点是Agent的“原子”,原子稳了,分子才不会散架。
三、集成测试:跑通Agent的“全链路马拉松”
单元测试过关,只代表每个零件是合格的。但零件拼成车,能不能开?还得看集成测试。在LangGraph里,集成测试验证的是:条件边(Conditional Edge)判断准不准?循环(Loop)会不会变成死循环?State在多个节点之间传递,有没有丢数据?工具调用完了,LLM能不能正确理解ToolMessage并继续?
新手最容易栽的坑,就是“Happy Path依赖症”。他在本地手动跑了一句“你好”,Agent回了句“你好”,他就觉得集成测试通过了。这哪够啊?
我见过一个真实的翻车案例:一个客服Agent,用户说“我要退款”,Agent应该走understand -> check_policy -> human_handoff这条路。结果因为check_policy节点修改了state里的refund_eligible字段,但新手在条件边里判断的是state.get("is_eligible"),字段名写错了!单元测试时这个节点单独跑,因为它自己mock了返回值,根本发现不了字段名错误。一上生产,所有退款请求都卡死在循环里,因为条件边永远匹配不到下一个节点。
还有死循环的问题。LangGraph里经常用should_continue来决定是继续调用工具还是返回用户。如果判断逻辑写反了,Agent就会陷入“LLM说我要查一下->查完了->LLM说我要查一下”的无限循环。没有集成测试,这种问题很难发现。更有甚者,用了Send做Map-Reduce并行,结果Reduce节点收不到所有分支的State,数据直接丢了一半。
集成测试要用LangGraph编译后的app来跑,覆盖完整的用户旅程。
首先,Happy Path要测,但更重要的是Sad Path和Edge Case。
def test_refund_flow_eligible():
app = build_customer_service_graph()
result = app.invoke({
"messages": [HumanMessage(content="我要退款,订单号12345")]
})
# 断言最终走到了人工处理节点,且state携带了正确信息
assert result["current_node"] == "human_handoff"
assert result["refund_eligible"] is True # 注意字段名正确
# 确认ToolMessage被正确处理
tool_msgs = [m for m in result["messages"] if isinstance(m, ToolMessage)]
assert len(tool_msgs) >= 1
def test_refund_flow_infinite_loop_guard():
app = build_customer_service_graph()
# 给一个会让LLM纠结的输入,触发循环保护
config = {"recursion_limit": 10} # LangGraph自带递归限制
with pytest.raises(GraphRecursionError):
app.invoke({
"messages": [HumanMessage(content="我就是要退款,不解释")]
}, config=config)
看到没?第二个测试专门验证了系统的自我保护能力。集成测试不是看“能不能跑通”,而是看“跑不通的时候,系统会不会优雅地挂掉”。
另外,一定要测条件边。你可以通过构造不同的State输入,直接触发不同的边:
def test_conditional_edge_routing():
from my_graph import route_by_intent
state_tech = {"intent": "technical"}
state_sales = {"intent": "sales"}
assert route_by_intent(state_tech) == "tech_node"
assert route_by_intent(state_sales) == "sales_node"
虽然route_by_intent本质上是个函数,但它属于Graph的编排逻辑,放在集成测试里验证非常必要。如果你的图用了interrupt或者checkpoint,集成测试还要验证人机交互的断点恢复能力。
单元测试保“每个零件能做对”,集成测试保“整台机器能走对路”。特别是那些条件边和循环,不测等于埋雷。
四、Mock策略:驯服LLM这个“薛定谔的猫”
如果说传统测试的Mock对象是数据库和HTTP接口,那Agent测试的Mock主角一定是LLM。它贵、它慢、它不稳定,而且它还是个“话痨”——你让它格式化输出JSON,它偏要在前面加一句“好的,以下是您要的数据”。不把它Mock住,你的测试就是一颗定时炸弹。
最粗暴的做法,是直接让测试调用真实LLM。一次CI跑50个测试,每个调3次GPT-4,你算算这账。而且网络波动会让测试随机失败,这种“Flaky Test”最恶心,红了你不敢忽略,绿了你不敢相信。
Mock的坑里还有“形似神不似”。比如有人这样Mock:
mock_llm = Mock()
mock_llm.invoke.return_value = "我会帮你搜索相关信息"
然后他的节点代码里,其实用的是AIMessage(content="", tool_calls=[...])来触发工具调用。测试一跑,类型错误,或者走到错误的分支,因为返回的字符串根本没有tool_calls属性。
还有一种情况是Mock得太“乖”。你的代码里有三个分支:工具调用、直接回复、要求澄清。结果你的Mock LLM永远返回“直接回复”的内容,另外两个分支的代码覆盖率永远是0。上线后用户一触发“要求澄清”,直接报错。这就跟训练AI只给它看猫的图片,结果一见到狗直接死机一个道理。
Mock LLM的核心原则是:结构对齐,行为分支,成本归零。
结构对齐,就是Mock的返回值必须是LangChain的Message对象,不能是裸字符串。
from langchain_core.messages import AIMessage
# 好的Mock:模拟LLM决定调用工具
mock_llm.invoke.return_value = AIMessage(
content="",
tool_calls=[{
"name": "search",
"args": {"query": "LangGraph testing"},
"id": "call_123"
}]
)
# 模拟工具返回后,LLM的最终回复
mock_llm_with_history.invoke.return_value = AIMessage(
content="根据搜索结果,你可以使用pytest进行单元测试。"
)
行为分支,就是根据输入返回不同响应。用side_effect实现:
def llm_side_effect(messages):
last_msg = messages[-1]
if isinstance(last_msg, ToolMessage):
# 如果上一条是工具返回,说明现在要总结
return AIMessage(content="总结完成")
if "搜索" in last_msg.content:
return AIMessage(content="", tool_calls=[{"name": "search", "args": {"q": "test"}, "id": "1"}])
if "你好" in last_msg.content:
return AIMessage(content="你好!有什么可以帮你的?")
return AIMessage(content="请再说清楚一点") # 要求澄清
mock_llm.invoke.side_effect = llm_side_effect
这样,一条测试链路里,LLM可以模拟“理解->调工具->收到结果->总结”的完整多轮对话,而且全是本地Mock,零API调用。
其实LangChain还自带了专门用于测试的Fake LLM,比如FakeListLLM和FakeMessagesListLLM,你可以直接在conftest.py里封装成fixture:
from langchain_core.language_models.fake import FakeListLLM
import pytest
@pytest.fixture
def fake_llm():
return FakeListLLM(responses=["模拟回复1", "模拟回复2"])
对于工具(Tool)的Mock也一样。如果你的Agent调用的是企业内部API,测试里一定要Mock掉requests.post或者直接把工具函数换成Mock。千万别在单元测试里发真实的网络请求,这是底线。
Mock不是造假,而是给测试造一个“可控的平行宇宙”。在这个宇宙里,LLM听话、工具稳定、一分钱不用花。
五、断言设计:在非确定性里寻找确定性
这是Agent测试最难的部分。传统测试assert actual == expected一招鲜吃遍天,但在Agent这里,LLM的回复就像你女朋友的脾气——大概率在合理范围内,但具体措辞你永远猜不透。怎么办?咱们得设计一套“柔性断言”体系。
新手写断言,要么硬得离谱,要么软得没边。
硬得离谱:
assert result["messages"][-1].content == "好的,已为您预订北京到上海的机票,明天上午9点出发。"
下次LLM说“已预订,京沪航线明早九点”,测试炸了。这种断言维护起来就是噩梦,LLM模型一升级,你的测试得重写一半。
软得没边:
assert result is not None
这测了跟没测一样。上线后Agent返回一堆胡言乱语,测试还是绿的,你当场社会性死亡。
Agent的断言要分维度、设边界、看结构。
第一,结构断言(Structure Assertion)。 不管LLM说了啥,返回的State必须有某些字段。
assert "messages" in result
assert isinstance(result["messages"][-1], AIMessage)
assert result["next_step"] in ["collect_info", "finish", "escalate"]
第二,语义断言(Semantic Assertion)。 不卡死措辞,卡死主题。
content = result["messages"][-1].content.lower()
expected_keywords = ["机票", "航班", "预订", "出发"]
assert any(k in content for k in expected_keywords), "回复未包含关键业务词"
assert "无法" not in content or "抱歉" not in content, "Agent不应直接拒绝"
第三,路径断言(Path Assertion)。 测Agent“经历了什么”。
# 验证消息历史里出现过ToolMessage,说明确实查了数据库
tool_calls = [m for m in result["messages"] if isinstance(m, ToolMessage)]
assert len(tool_calls) > 0, "必须经过工具查询环节"
# 验证状态变迁
assert result["session_state"]["step_count"] <= 5 # 防止过多轮数
第四,Schema断言。 如果你用了LangGraph的结构化输出(比如强制LLM返回JSON),直接用Pydantic验证:
from pydantic import ValidationError
try:
BookingResponse.model_validate_json(result["structured_output"])
except ValidationError as e:
pytest.fail(f"结构化输出不符合预期: {e}")
第五,容忍度配置。 给LLM-based的断言设置重试和阈值。比如在集成测试里,某个用LLM做eval的测试,允许跑3次,2次通过就算过(对付LLM的偶尔抽风)。当然,这种“以LLM测LLM”的方法成本高,放在集成测试的 nightly 任务里更合适,别在单元测试里滥用。
对Agent的断言,要像面试官考察候选人一样:不追求标准答案一字不差,但必须在能力边界和知识结构上证死。
六、工程闭环:把测试铸成CI/CD的护城河
测试写在本地,那叫“自嗨”。只有嵌进CI/CD流水线,自动化、可重复、有反馈,才算真正建立了“体系”。对于LangGraph Agent项目,工程化落地有几个特别需要注意的坑。
新手们的工程实践往往惨不忍睹。测试文件随意丢在根目录,命名随心所欲;pytest.ini没配置,导致测试发现不了;CI配置文件里直接export OPENAI_API_KEY=sk-xxx,密钥泄露到GitHub上,第二天醒来发现API额度被刷光。
更常见的是速度问题。一个集成测试跑30秒,50个测试跑25分钟,流水线慢到开发想杀人。还有测试之间的隐性依赖:测试A在数据库里插了条数据,测试B假设数据库是空的,结果本地单独跑B能过,CI里一起跑就挂,这种“测试毒气”能把团队逼疯。
1. 目录与命名规范
project/
├── src/
│ └── my_agent/
│ ├── graph.py
│ └── nodes.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 共享fixture
│ ├── unit/ # 单元测试
│ │ ├── test_nodes.py
│ │ └── test_edges.py
│ └── integration/ # 集成测试
│ ├── test_full_flow.py
│ └── test_routing.py
conftest.py里放公共的Mock fixture,比如FakeLLM、MemorySaver,避免重复造轮子。
2. 快慢分离,标记清晰
# conftest.py或测试文件顶部
import pytest
@pytest.fixture
def mock_llm():
from langchain_core.language_models.fake import FakeListLLM
return FakeListLLM(responses=["模拟回复"])
@pytest.mark.slow # 标记慢测试
def test_e2e_with_real_embedding():
...
运行时用pytest -m "not slow"跳过慢测试,日常开发飞快; nightly build或合并前再跑全量。
3. CI/CD配置
不要直接在CI里调真LLM!所有单元测试必须基于Mock。集成测试如果需要调真实模型,走专门的 nightly 任务,并且用最低成本的模型或者专门的测试密钥。
流水线分阶段:
- Stage 1: Lint + Unit Test(必须全过,2分钟内)
- Stage 2: Integration Test(合并前必过,5-10分钟)
- Stage 3: Coverage Report(行覆盖率只是参考,关注关键路径覆盖)
4. 环境隔离
测试用内存版的MemorySaver,别连生产Redis。测试数据用Factory模式生成,测完自动清理。Graph实例在fixture里统一编译,避免每个测试文件都重复builder.compile()。
5. 覆盖率的心态
传统项目追求90%行覆盖,Agent项目别钻牛角尖。一个节点的核心逻辑覆盖到了,比盲目追求覆盖数字有用。重点关注:条件边分支、工具调用异常处理、State字段变更。
没有CI/CD的测试,就像没有刹车的超跑——看着猛,出事快。把测试嵌进流水线,才是对线上用户真正的负责。
写在最后
咱们今天聊了这么多,从认知破局到单元测试,从集成测试到Mock策略,再到断言设计和工程闭环。说白了,构建Agent的测试体系,就是在“不确定性”的沼泽里,铺出一条“确定性”的栈道。
我知道,很多老铁一开始会觉得麻烦。“我写业务代码都写不完,还要写这么多测试?”但你想啊,LangGraph Agent这种系统,节点多、链路长、LLM还时不时抽风,你要是没有测试护体,每次改个Prompt或者加个节点,都得手工跑几十条case验证,那才叫真的累。测试前期确实要投入,但它是在给你的项目“买保险”,还是在赔率最高的那一环。
编程之路不易,但每一步扎实的成长都算数。别怕写测试,它不是你的敌人,而是你深夜上线时最靠谱的那个战友。保持好奇,持续学习,你也能成为那个既能写出漂亮Agent、又能让它稳如老狗的代码高手。咱们下回见!
关注私信备注:“资料代找获取”,全网计算机学习资料代找:例如:
《课程:AI 大模型工程师系统课程 (22 章完整版 持续更新)》
《课程:AI 大模型系统实战课第四期 (2026 年开课 持续更新)》
《课程:2026 年 AGI 大模型系统课 23 期》
《课程:2026 年 AGI 大模型系统课 21 期》
《课程:AI 大模型实战课 8 期 (2026 年 2 月最新完结版)》
《课程:AI 大模型系统实战课三期》
《课程:AI 大模型系统课程 (2026 年 2 月开课 持续更新)》
《课程:AI 大模型全阶课程 (2025 年 12 月开课 2026 年 6 月结课)》
《课程:AI 大模型工程师全阶课程 (2025 年 10 月开课 2026 年 4 月结课)》
《课程:2026 年最新大模型 Agent 开发系统课 (持续更新)》
《课程:LLM 多模态视觉大模型系统课》
《课程:大模型 AI 应用开发企业级项目实战课 (2026 年 1 月开课)》
《课程:大模型智能体线上速成班 V2.0》
《课程:Java+AI 大模型智能应用开发全阶课》
《课程:Python+AI 大模型实战视频教程》
《书籍:软件工程 3.0: 大模型驱动的研发新范式.pdf》
《课程:人工智能大模型系统课 (2026 年 1 月底完结版)》
《课程:AI 大模型零基础到商业实战全栈课第五期》
《课程:Vue3.5+Electron + 大模型跨平台 AI 桌面聊天应用实战 (2025)》
《课程:AI 大模型实战训练营 从入门到实战轻松上手》
《课程:2026 年 AI 大模型 RAG 与 Agent 智能体项目实战开发课》
《课程:大模型训练营配套补充资料》
更多推荐

所有评论(0)