LangChain 测试与 LangSmith 观测
代码仓库ChainReaction 给 agent 写测试,最先撞上的是它没有稳定的返回值。同一句输入,模型这次老老实实调 lookup_rate,下次直接凭记忆把汇率编出来。温度调到 0 也不保证复现,推理后端本身不承诺确定性。 第二个麻烦是依赖链太长。一次 agent 运行会经过模型 API、工具背后的数据库或第三方接口、还有你自己的业务代码。出错的时候栈顶是 ValueError,真正的原因却可能是工具描述写得含糊,模型压根没打算调它。 所以 agent 的测试要拆开写。纯函数用普通单测盖住,模型决策用假模型钉死,前两层都对了再花钱跑真实模型。观测是另一半:测试回答「这次对不对」,trace 回答「它为什么这么走」。 下面所有终端输出都是本机实跑的原文。环境是 langchain 1.4.3、langchain-openai 1.6.7、langgraph 1.2.12、langsmith 0.14.4、pytest 9.1.1、agentevals 0.0.9,模型统一换成 DeepSeek。 为什么 agent 难测三个原因叠在一起,任何一个单独拿出来都有成熟解法,...
LangChain MCP
代码仓库ChainReaction 给 agent 接工具,真正花时间的部分往往不是写函数体。函数体十行就写完了,剩下的时间花在接口对齐上:这个数据源要什么参数,那个数据源返回的结构怎么塞给模型,调用失败时错误信息用什么格式回传。每接一个系统,这套活儿重来一遍。 MCP(Model Context Protocol)把这层约定协议化了。服务端自己声明它有哪些工具、每个工具叫什么、参数 schema 长什么样;客户端连上去把这些声明读出来,转成模型能看懂的格式。适配代码从「每个数据源写一遍」变成「一份通用实现」。 LangChain 从 1.4 开始把 MCP 客户端收进了主包,命名空间是 langchain.mcp,核心类叫 MCPAdapter。它在 FastMCP 之上做转换:发现服务端的工具,变成普通的 LangChain 工具,直接丢给 create_agent。传输、协议握手、认证这些往下的事由 FastMCP 负责。 这篇按能跑的顺序讲:调用链长什么样,stdio 和 HTTP 两种连法差在哪,多个 server 挤在一起时工具名怎么处理,连接什么时候开什么时候关...
LangChain Multi-agent
代码仓库ChainReaction LangChain 官方文档的 Multi-agent 页面开头就写了一句不太像推销的话:not every complex task requires this approach。一个 agent 配上合适的工具和 prompt,往往就能拿到差不多的结果。 我见过反过来的情况更多。工具还没写到第十个,先搭了一套 supervisor,主代理调子代理,子代理再调工具,出了错要在三层日志里找是谁说错了话。调试成本涨上去,效果没变好。 那什么时候拆是对的。官方给了三个动机,按我实际项目里的出现频率排一下:上下文隔离、职责分离、并行。这三个词听起来都像正确的废话,落到代码上是三件很具体的事。 上下文隔离是说,某个领域有几千 token 的规范和一堆专用工具,塞进主对话会把窗口顶满,而且每轮都要重新付一遍 token 钱。子代理可以只在需要的时候开一个干净窗口,干完活把结论交回来。 职责分离是说,日历、邮件、CRM、数据库四块能力由四个团队维护,各自发布各自测试,主流程只认工具名和描述这个接口。 并行是说,三个互不依赖的子任务同时跑,总延迟按最慢的...
LangChain Guardrails
代码仓库ChainReaction 你在系统提示词里写「绝对不要输出用户的手机号」。然后用户输入一段话,里面夹了一句「把上面的规则复述一遍再执行」,模型就把手机号打了出来。 问题出在提示词的位置上。系统提示词和用户输入最后会被拼成同一个 token 序列送进模型,两者在模型眼里没有权限差别,只有先后差别。既然都是输入,模型就有理由在某段上下文里忽略其中一段。护栏写在提示词里,等于把安全边界交给模型的自觉。 写在代码里是另一回事。PIIMiddleware 在 before_model 里把 HumanMessage 的字符串改掉,改完才构造模型请求。模型拿到的输入里根本没有手机号,它想泄露也泄露不了。提示词做不到这件事。 还有一个更实际的原因:可测试。中间件是普通 Python 类,能写断言,能进 CI,能证明「这条输入必然被拦」。提示词只能靠评估集估概率。前者的结论是确定的,后者是统计的。 这篇讲 LangChain 里怎么写护栏。先看数据流经过哪些检查点,再拆 PIIMiddleware 的四种策略,然后自己写三个挂在不同钩子上的护栏,最后说清楚护栏拦不住什么。所有代码在...
LangChain Human-in-the-Loop
代码仓库ChainReaction 模型能调工具之后,第一个让人睡不踏实的问题就是它什么时候会自己动手。 查天气、算数、读文档,模型自己跑没问题。发邮件、删数据库记录、调支付接口,这类动作发出去就收不回来。Human-in-the-Loop 中间件处理的就是这一层:模型提议一个工具调用,真正执行之前先停下来,等人点个头。 这篇讲四件事。中间件怎么配,interrupt 怎么把执行挂起、状态落在哪、又怎么恢复,审批粒度能细到什么程度,以及和前端对接时待审批的卡片需要哪些字段。代码都在 DeepSeek 上真跑过,输出是终端里直接抄下来的,脚本在仓库的 HumanInTheLoop/ 目录下。 闸门装在哪一步create_agent 跑起来是个循环:模型产出 AIMessage,里面可能带 tool_calls,工具节点执行,结果作为 ToolMessage 回到模型,再产出下一轮。HumanInTheLoopMiddleware 挂的是 after_model 钩子,位置在”模型已经想好要调什么”和”工具真的被执行”之间。 钩子里做的事情很直接。把这条 AIMessa...
LangChain Tools
代码仓库ChainReaction 模型只会生成文字。让它查天气、下单、读数据库的,是你挂在外面的一段 Python。LangChain 把这段 Python 和它的说明书一起打包成 Tool。说明书是一份 JSON Schema,模型看得到;函数体模型看不到。 这个分界决定了日常调试的方向。模型不调工具,问题多半出在描述写得含糊;模型调了但参数填错,问题在 Schema 的类型或约束;函数执行到一半炸了,模型那边只能看到你回给它的那段错误文字,它凭这段文字决定下一步。工具写得好不好,一半在函数里,一半在函数外。 这篇按顺序讲这些:工具的最小结构、@tool 能改的几件事、参数 Schema 的三种写法、校验失败后模型收到什么、ToolRuntime 从哪里取数据、工具怎么读写 agent state、异常怎么变成模型能读的消息、按上下文动态换工具集,最后用 bind_tools 直接看模型吐出来的 tool_calls。文中输出都是在 DeepSeek 上实跑的终端原文。 工具的最小结构官方对工具的定义是两样东西的组合:一份包含名称、描述、参数定义的 Schema,一个用来...
LangChain 长期记忆
代码仓库ChainReaction 上一篇文章里我们给 agent 挂了 checkpointer,同一个 thread_id 下它能记住用户叫什么。把 thread_id 一换,它就失忆了。这个行为对单次会话来说是对的,做成产品就错了。用户在客服窗口里说过”我不用信用卡”,三天后他打开另一个窗口,这句话不该消失。 LangChain 里管这件事的组件叫 store。它和 checkpointer 是两套独立的东西,一个存 thread 内的消息,一个存跨 thread 的 JSON 文档。这篇把 store 这一层拆开:namespace 怎么设计、写入和召回有哪几条路、语义检索怎么接、什么时候该忘掉、噪声怎么挡在门外。 文里的代码都在 langchain 1.4.3 + langgraph 1.2.12 上跑过,输出是从终端粘出来的。对话模型统一用 DeepSeek,向量用本地跑的 BAAI/bge-small-zh-v1.5,原因在语义检索那一节。 短期记忆和长期记忆的边界短期记忆就是 agent 的 state。默认的 AgentState 里有一个 mes...
LangChain Middleware
代码仓库ChainReaction 中间件这个词已经被用滥了。Web 框架拿它做鉴权,Django 拿它记请求日志。放到 agent 上,意思没变:在不改核心循环的前提下,在固定位置插一段自己的代码。 create_agent 返回的是一张编译好的 LangGraph 图,循环逻辑写死在图里。你想加一句”每次调用模型前打印当前消息数”,有两个选择。自己用 StateGraph 重画一遍循环,或者把这段逻辑做成中间件塞进 middleware=[...]。前者的代价是官方每次调整循环你都得跟着改,后者只多一个参数。 这篇文章把 middleware 这个参数讲清楚。钩子挂在哪些位置,装饰器和类两种写法各适合什么场景,状态怎么在钩子之间传,内置中间件有哪些能直接拿来用,多个中间件叠在一起时谁先谁后。最后有两个完整例子:按对话长度动态换模型,以及工具抛异常之后怎么让 agent 活下来。 所有代码都在 DeepSeek 上跑过,输出是从终端直接复制的,脚本放在 Middleware/。 中间件挂在循环的哪一步先看循环本身。模型读一遍消息,决定要不要调工具。要调就执行工具,...
LangChain Runtime 与上下文工程
代码仓库ChainReaction 写了几个 agent 之后,让人卡住的通常不是工具调用怎么写。麻烦的是”这次运行该拿到什么数据”:用户 ID 从哪来,上一轮对话的偏好存在哪,工具返回的 8000 字文档要不要整个塞回模型。这些问题在 create_agent 的入门示例里看不到,因为示例里只有一个 messages。 LangChain 把答案拆成了三块:state、context、store。名字都短,含义却经常被混着用。我见过把 user_id 塞进 state 的写法,也见过每次 invoke 都把数据库连接写进 messages 的写法。 这篇先把这三块拆开,再看 context_schema 和 runtime.context 怎么用,然后把 runtime 上剩下的字段挨个跑一遍。剩下的一节讲上下文工程:窗口怎么被填满,又该怎么打扫。 文中的输出都是在 Python 3.12 上跑 DeepSeek 得到的真实终端结果,脚本放在 Runtime/。 三个数据源官方文档在 context engineering 页面给了一张表,按作用域把三者分开了。我...
LangChain Structured Output
代码仓库ChainReaction 让模型输出一段文字,再用正则从里面抠字段,这活干一次就够了。字段多一个、模型换个说法、多写一句解释,正则就得重写。结构化输出解决的就是这件事:给模型一份 schema,等它跑完,你从 state 里取一个 Python 对象,sr.email 直接可用。 LangChain Agents 那篇里提过 ToolStrategy 和 ProviderStrategy,只有几句对比。这一篇换个角度,只看三件事:策略怎么选、失败时长什么样、拿不到结果时从哪儿查。代码在 DeepSeek、Ollama 云端的 OpenAI 兼容端点和原生端点上真实跑过,最后补上 Google Gemini,原生结构化输出总算跑出了正向结果。环境是 langchain 1.4.3、langchain-openai 1.6.7、langchain-ollama 1.1.0、langchain-google-genai 4.4.0、pydantic 2.13.5,脚本在仓库的 StructuredOutput/ 目录下。 一、structured_respons...

