1. 本章目标 #
第 29 章说清楚了「为什么要看」,这一章解决「怎么看得见」。
好消息是接入极简单——三行环境变量,一行代码都不用改。坏消息是,接入之后你多半会遇到一两个让人摸不着头脑的问题:界面上一条数据都没有、或者报一个 401 Invalid token 但密钥明明是对的。本章会把这些坑连同它们的排查过程一起讲完。
学完你应能:
- 用三行配置让现有代码产生 Trace,不改任何业务逻辑
- 说清
LANGSMITH_TRACING、LANGSMITH_API_KEY、LANGSMITH_PROJECT各管什么 - 判断三个流传很广的说法哪些是真的:项目名必须在 import 前设置、项目要手动建、不 flush 会丢数据
- 独立排查「密钥是对的却报 401」——这个坑的根因不在 LangSmith
- 用
@traceable给不用 LangChain 的普通函数加 Trace - 用 tags / metadata 做比「分项目」更灵活的切分
- 用
hide_inputs/hide_outputs在上报前脱敏 - 在测试、CI、本地调试时精确地关掉 tracing
前置依赖: 第 2 章(create_agent)、第 29 章(Trace / Run / Span 三个词)。
参考文档:
1.1 本章统一环境 #
langchain 1.3.18
langchain-core 1.6.1
langgraph 1.2.11
langsmith 0.12.1
模型 deepseek-v4-flash本章所有结论都在这套环境下实测。特别是第 4 节那三个「常见说法」,实测结果和多数教程写的不一样,请以本章的实测为准,并在你自己的版本上复验。
2. 最小接入:三行环境变量 #
先注册账号拿密钥:打开 smith.langchain.com,注册后在 Settings → API Keys 里创建一个,格式是 lsv2_pt_ 开头的长字符串。
然后在项目根目录的 .env 里加三行:
# 总开关。只有它是 true 时才会上报
LANGSMITH_TRACING=true
# 你的密钥。注意是 LANGSMITH_ 前缀,不是 LANGCHAIN_
LANGSMITH_API_KEY=lsv2_pt_你的密钥
# 数据落到哪个项目。不填就落到名为 default 的项目
LANGSMITH_PROJECT=my-first-project第四行是可选的,自建部署或换区域时才需要:
# 官方云默认就是这个地址,用官方云可以不写
LANGSMITH_ENDPOINT=https://api.smith.langchain.com业务代码一行都不用改。 拿第 2 章那个 Agent 原封不动跑一遍:
# 读 .env。注意这一行的位置很关键,第 5 节会专门讲
from dotenv import load_dotenv
load_dotenv(override=True)
from langchain.agents import create_agent
from langchain_core.tools import tool
# 工具一:查订单
@tool
def get_order(order_id: str) -> str:
"""按订单号查询订单状态。"""
import time
time.sleep(0.4) # 模拟数据库延迟,让 Trace 里的耗时看得出来
data = {"A1001": "已发货,预计 3 天到达", "A1002": "待付款"}
return data.get(order_id, f"未找到订单 {order_id}")
# 工具二:查政策
@tool
def get_policy(topic: str) -> str:
"""查询售后政策。topic 可选:退货 / 换货 / 发票。"""
return {"退货": "签收后 7 天内可无理由退货",
"换货": "质量问题 30 天内可换",
"发票": "支持开具电子发票"}.get(topic, "未找到该政策")
agent = create_agent(
model="deepseek:deepseek-v4-flash",
tools=[get_order, get_policy],
system_prompt="你是电商客服,用中文简洁回答。需要数据时调用工具。",
)
# 就这么调,跟没接 LangSmith 时完全一样
out = agent.invoke({"messages": [
{"role": "user", "content": "订单 A1001 到哪了?另外多久内能退货?"}]})
print(out["messages"][-1].content)跑完打开 smith.langchain.com,在项目列表里点进 my-first-project,就能看到第一条 Trace。
2.1 第一条 Trace 长什么样 #
界面上看到的树,用 API 拉回来是这样(这是实测输出,不是示意):
[chain ] LangGraph 2.552s 1188tok
[chain ] model 1.409s 555tok
[llm ] ChatDeepSeek 1.342s 555tok
[chain ] tools 0.403s
[tool ] get_order 0.401s
[chain ] tools 0.003s
[tool ] get_policy 0.001s
[chain ] model 0.610s 633tok
[llm ] ChatDeepSeek 0.608s 633tok
共 9 个 span,run_type 分布: {'chain': 5, 'llm': 2, 'tool': 2}对照第 29 章的概念检查一遍:
- 9 个 Run,同属一个 Trace
- 根 Run 是
LangGraph,耗时 2.552 秒覆盖全部 - 两个工具各有一个
tools节点(模型在同一轮里发起了两个工具调用) get_order花了 0.401 秒——正是我们time.sleep(0.4)那一下
根 Run 上的汇总信息:
status = 'success'
latency = 2.552s
total_tokens = 1188 (prompt=1038, completion=150)
total_cost = 6.43888e-05接入到此为止。 剩下的内容都是让它更好用、以及避开那些让你怀疑人生的坑。
3. 三个环境变量各管什么 #
| 变量 | 作用 | 不设会怎样 |
|---|---|---|
LANGSMITH_TRACING |
总开关,只认 true |
不上报,其他变量全部失效 |
LANGSMITH_API_KEY |
身份凭证 | 报 401,且报错信息会误导你(第 5 节) |
LANGSMITH_PROJECT |
数据落到哪个项目 | 落到 default 项目 |
LANGSMITH_ENDPOINT |
服务地址 | 用官方云 https://api.smith.langchain.com |
3.1 关于旧变量名 #
老教程里你会看到 LANGCHAIN_TRACING_V2 和 LANGCHAIN_API_KEY。它们仍然能用(为了向后兼容),但新代码统一用 LANGSMITH_ 前缀。
真正要注意的是别混着用:
# 别这样。两套前缀混用时优先级容易搞错
LANGCHAIN_TRACING_V2=true
LANGSMITH_API_KEY=lsv2_pt_xxx3.2 代码里怎么确认开关状态 #
不要靠猜,有现成的函数:
from langsmith import utils
# 综合判断当前是否会上报(会考虑环境变量和 tracing_context)
print(utils.tracing_is_enabled()) # True实测输出:
LANGSMITH_TRACING = 'true'
LANGSMITH_ENDPOINT = 'https://api.smith.langchain.com'
LANGSMITH_PROJECT = 'ls-course-ch30'
tracing_is_enabled() = True这个函数比自己读 os.getenv("LANGSMITH_TRACING") 靠谱,因为它还会考虑第 9 节讲的 tracing_context 临时开关。
4. 三个常见说法,实测辨真伪 #
网上关于 LangSmith 接入有几个流传很广的说法。我把它们逐个做了对照实验,结果和多数教程写的不一样。
4.1 说法一:LANGSMITH_PROJECT 必须在 import 之前设置 #
这是最常见的一条建议,通常写成:
# 很多教程强调这个顺序
import os
os.environ["LANGSMITH_PROJECT"] = "my-project" # 必须在这之前设!
from langsmith import traceable # 否则不生效实测三种时机:
# 变体 A:import 之前设
os.environ["LANGSMITH_PROJECT"] = "ls-course-timing-before-import"
from langsmith import traceable
# 变体 B:import 之后设
from langsmith import traceable
os.environ["LANGSMITH_PROJECT"] = "ls-course-timing-after-import"
# 变体 C:连 Client 都建完了再设
from langsmith import traceable, Client
c = Client()
os.environ["LANGSMITH_PROJECT"] = "ls-course-timing-after-client"每个变体跑一个 @traceable 函数,在函数内部打印 get_current_run_tree().session_name(Run 实际落到的项目):
变体 before-import 实际项目: ls-course-timing-before-import
变体 after-import 实际项目: ls-course-timing-after-import
变体 after-client 实际项目: ls-course-timing-after-client
最终落库确认:
ls-course-timing-before-import 存在=True run 数=1
ls-course-timing-after-import 存在=True run 数=1
ls-course-timing-after-client 存在=True run 数=1三种都正确。这个说法在 langsmith 0.12.1 上不成立。
原因是项目名是在每个 Run 开始的那一刻才去读环境变量的,不是 import 时缓存下来的。所以你随时改都来得及。
不过还是建议把它放在文件顶部——不是因为技术上必须,而是因为可读性。让读代码的人一眼看到这份代码往哪个项目发数据,比藏在中间某一行强。
4.2 说法二:项目要先手动建好 #
实测:用一个从没出现过的项目名直接跑。
os.environ["LANGSMITH_PROJECT"] = "ls-course-autocreate-412736"创建前 project_id = None
函数返回 42
创建后 project_id = a39ae0a2-45c6-4a1a-a517-7556a0de352f(等了约 4s 自动出现)会自动创建,4 秒左右就能查到。 不需要提前在界面上点「New Project」。
4.3 说法三:不调 flush() 会丢数据 #
上报是后台线程异步做的,所以「进程退出太快会丢数据」这个担心听起来很合理。实测:
# 跑完一个 traceable 函数,不调 flush,直接让脚本结束
f(21)
print("跑完了,不调 flush 直接退出")跑完了,不调 flush 直接退出
→ 项目出现了(等了约 4s):说明进程退出时有 atexit 兜底正常退出时不会丢,SDK 注册了 atexit 钩子会把队列排干。
但有两种情况仍然会丢,这时才需要手动 flush:
| 情况 | 为什么会丢 |
|---|---|
os._exit() / SIGKILL / 容器被强杀 |
绕过了 atexit |
| 长期运行的服务(Web 后端) | 进程不退出,数据一直在队列里等批量条件 |
手动 flush 的正确写法有个陷阱:
# ✗ 错的。这是新建了一个 Client,flush 的是它自己的空队列,
# 真正装着 trace 的那个全局 client 完全没动
from langsmith import Client
Client().flush()
# ✓ 对的。拿到 LangChain tracing 实际在用的那个 client
from langchain_core.tracers.langchain import get_client
get_client().flush()如果你用的是 @traceable(不经过 LangChain),那 Client().flush() 是对的——因为 @traceable 默认就用全局缓存的 client。混着用时以 get_client() 为准。
4.4 小结 #
| 说法 | 实测结论 |
|---|---|
| 项目名必须在 import 前设 | 不成立,任何时候设都行;但放顶部更易读 |
| 项目要手动建 | 不成立,自动创建,约 4 秒可见 |
| 不 flush 会丢数据 | 正常退出不会丢;强杀和长期服务才需要手动 flush |
Client().flush() 能刷 LangChain 的数据 |
不成立,要用 get_client().flush() |
5. 排障实录:密钥是对的,却报 401 #
这一节是本章最值得读的部分。它是一次真实排查的完整记录,而且根因不在 LangSmith——这类问题最耗时间,因为报错信息把你往完全错误的方向引。
5.1 现象 #
.env 配好了,密钥从界面复制粘贴,确认无误。跑起来报:
langsmith.utils.LangSmithAuthError: Authentication failed for /datasets.
HTTPError('401 Client Error: Unauthorized for url:
https://api.smith.langchain.com/datasets', '{"detail":"Invalid token"}')Invalid token。第一反应当然是密钥有问题。
5.2 排查过程 #
第一步:验证密钥本身。 绕开 SDK,用最原始的方式打一次:
import requests
from dotenv import dotenv_values
k = dotenv_values(".env")["LANGSMITH_API_KEY"]
r = requests.get("https://api.smith.langchain.com/sessions?limit=1",
headers={"x-api-key": k}, timeout=20)
print(r.status_code) # 200200。密钥完全有效。 那问题在 SDK 侧。
第二步:观察规律。 反复试,发现一个奇怪的模式:
uv run python -c "...同样的代码..." → 成功
uv run python C:\Temp\ls\script.py → 401同一台机器、同一份代码、同一个密钥,内联执行成功,脚本文件执行失败。
这个现象排除了「网络问题」「配额用尽」「服务端限流」这些猜测——它们不可能只影响其中一种执行方式。
第三步:找差别。 两种方式唯一的区别是 __file__ 的位置。而代码里唯一和文件位置有关的是……
from dotenv import load_dotenv
load_dotenv(override=True)第四步:验证。 同一份探测代码,放在两个位置分别跑:
import os
from dotenv import load_dotenv, find_dotenv
print("cwd =", os.getcwd())
print("__file__ dir =", os.path.dirname(os.path.abspath(__file__)))
print("find_dotenv() =", repr(find_dotenv()))
load_dotenv(override=True)
k = os.getenv("LANGSMITH_API_KEY")
print("key after load =", (k[:12] + "..." if k else None))脚本在项目目录内 (D:\forever\docs\langrag\_probe.py)
cwd = D:\forever\docs\langrag
__file__ dir = D:\forever\docs\langrag
find_dotenv() = 'D:\\forever\\docs\\langrag\\.env'
key after load = lsv2_pt_dc37...
脚本在 Temp 目录 (C:\Users\...\Temp\_probe.py)
cwd = D:\forever\docs\langrag ← 工作目录是对的!
__file__ dir = C:\Users\...\Temp
find_dotenv() = '' ← 但没找到 .env
key after load = None ← 密钥是 None5.3 根因 #
load_dotenv()是从「调用它的那个脚本所在目录」向上查找.env,不是从当前工作目录查找。
脚本放在 Temp 目录时,find_dotenv() 一路往上找到 C:\ 都没有 .env,返回空字符串。load_dotenv() 于是什么都没加载,LANGSMITH_API_KEY 是 None。
而 SDK 拿着 None 去请求,服务端返回 401 {"detail":"Invalid token"}——它说的是实话,token 确实无效,因为压根没有 token。 误导人的是我们的假设:我们以为 token 是 .env 里那个。
python -c 之所以能成功,是因为内联代码没有 __file__,find_dotenv() 退化成从当前工作目录找,而工作目录恰好是项目根目录。
5.4 怎么避免 #
方案一:显式指定路径(推荐,最稳)
from pathlib import Path
from dotenv import load_dotenv
# 用 __file__ 算出项目根目录,不依赖脚本从哪儿启动
ROOT = Path(__file__).resolve().parent # 按实际层级调整 .parent 个数
load_dotenv(ROOT / ".env", override=True)方案二:加载后立刻断言
import os
from dotenv import load_dotenv
load_dotenv(override=True)
# 三行断言,能省下半天排查时间
assert os.getenv("LANGSMITH_API_KEY"), (
"LANGSMITH_API_KEY 没读到。检查 .env 位置:"
"load_dotenv 是从脚本所在目录往上找的,不是从 cwd"
)方案三:确认 find_dotenv() 的结果
from dotenv import find_dotenv
print("找到的 .env:", find_dotenv() or "(没找到!)")5.5 值得记住的三点 #
这个案例的价值不在于 dotenv 本身,而在于排查方法:
401 Invalid token不一定代表密钥错了,也可能是密钥根本没传。 遇到认证错误,先确认凭证读到了没有,再怀疑它对不对。能稳定复现的「随机故障」,一定有个被你忽略的变量。 一开始我以为是网络抖动,因为成功和失败交替出现——实际上是我在两种执行方式之间来回切。先找出「什么情况下必然成功、什么情况下必然失败」,比统计成功率有用得多。
绕开封装直接打原始请求,是最快的分界手段。
requests一行就把问题范围从「LangSmith 整条链路」缩到了「SDK 侧」。
6. @traceable:给普通函数加 Trace #
不是所有代码都在 LangChain 里。你的检索前处理、业务规则校验、缓存查询都是普通 Python 函数,它们默认不会出现在 Trace 里——于是树上就有了断层:明明花了 3 秒,Trace 里只看到 1 秒的模型调用,另外 2 秒不知去向。
@traceable 就是补这个断层的。
from langsmith import traceable
from langsmith.run_helpers import get_current_run_tree
# 用法一:什么都不传。run_type 默认 'chain',显示名默认取函数名
@traceable
def clean(text: str) -> str:
"""文本预处理。"""
return text.strip().lower()
# 用法二:指定类型和显示名。中文名也可以,界面上直接显示
@traceable(run_type="tool", name="查缓存")
def cache_lookup(key: str) -> str | None:
return {"abc": "命中的值"}.get(key)
# 用法三:带标签和元数据。这两个之后可以用来筛选
@traceable(run_type="chain", tags=["ch30", "pipeline"],
metadata={"env": "dev", "ver": "v1"})
def handle(text: str) -> dict:
"""外层函数。内层调用会自动成为它的子 Run,不用手工串联。"""
rt = get_current_run_tree() # 拿到当前 Run 对象
print(f"当前 Run id={rt.id}")
print(f" trace_id = {rt.trace_id}")
print(f" session_name = {rt.session_name}") # 落到哪个项目
k = clean(text) # ← 自动成为 handle 的子 Run
v = cache_lookup(k) # ← 同上
return {"key": k, "hit": v}
print(handle(" ABC "))实测输出:
当前 Run id=01a065b3-b522-71e2-936b-671846be0e90
trace_id = 01a065b3-b522-71e2-936b-671846be0e90
session_name = ls-course-ch30
{'key': 'abc', 'hit': '命中的值'}上报后在项目里查到的结构:
▸ handle tags=['ch30', 'pipeline'] metadata={'env': 'dev', 'ver': 'v1'}三点值得注意:
- 嵌套是自动的。 只要
clean是在handle执行期间被调用的,它就是handle的子 Run。靠的是contextvars,不用你手工传 parent。 run_type影响界面渲染。 标成tool的会用工具样式显示(突出入参和返回值),标成retriever的会用文档列表样式。选一个语义贴切的。get_current_run_tree()可能返回None。 tracing 关闭时就是None(第 9 节会演示)。生产代码里访问它的属性前要判空,否则会抛AttributeError: 'NoneType' object has no attribute 'id'。
6.1 混用:@traceable 和 LangChain 在同一棵树上 #
两者会自动拼成一棵树,不需要额外配置:
@traceable(run_type="chain", name="完整问答流程")
def full_pipeline(question: str) -> str:
# 这一段是纯 Python,靠 @traceable 才可见
q = clean(question)
# 这一段是 LangChain,它会自动挂到 full_pipeline 下面
out = agent.invoke({"messages": [{"role": "user", "content": q}]})
return out["messages"][-1].content产生的树:
[chain] 完整问答流程
[chain] clean
[chain] LangGraph
[chain] model
[llm] ChatDeepSeek
...7. 项目怎么划分 #
项目是 LangSmith 最粗的一层归类。常见的分法有三种:
| 分法 | 例子 | 适合 |
|---|---|---|
| 按环境 | myapp-dev / myapp-staging / myapp-prod |
默认选它 |
| 按功能 | customer-service / rag-search / report-gen |
一个系统里有多个独立 Agent |
| 按人 | alice-dev / bob-dev |
团队共享一个 workspace,各自调试互不干扰 |
最常见的组合是「按环境 + 按功能」:
# 生产环境的客服 Agent
LANGSMITH_PROJECT=customer-service-prod7.1 别用项目做细粒度切分 #
一个常见的误用是给每次实验、每个用户、每个版本各建一个项目。这会带来两个问题:
- 项目一多,界面上找起来很痛苦
- 跨项目没法对比——LangSmith 的对比视图是在项目内做的
细粒度的切分应该用 tags 和 metadata(下一节),它们是在项目内部做过滤,随时能切换视角。
一个实用的判断标准:
需要「分开看」的用项目,需要「对比着看」的用 tags / metadata。
8. tags 和 metadata:更灵活的切分 #
不改代码结构,在调用时通过 config 传:
r = agent.invoke(
{"messages": [{"role": "user", "content": "3 加 5 等于几"}]},
config={
# 改根 Run 的显示名。默认叫 'LangGraph',一堆都叫这个很难找
"run_name": "算术-自定义名字",
# 标签:一维的字符串列表,用来筛
"tags": ["ch30", "arith", "v2"],
# 元数据:键值对,能存结构化信息
"metadata": {"user_id": "u_123", "release": "2026.09"},
},
)实测查回来:
▸ 算术-自定义名字 tags=['v2', 'ch30', 'arith']
metadata={'release': '2026.09', 'user_id': 'u_123'}8.1 两者的区别 #
| tags | metadata | |
|---|---|---|
| 类型 | list[str] |
dict,值可以是任意 JSON |
| 适合 | 少量、可枚举的分类 | 高基数、结构化的信息 |
| 例子 | ["prod", "v2", "rag"] |
{"user_id": "u_123", "tenant": "acme"} |
| 查询语法 | has(tags, "prod") |
eq(metadata_key, "value") |
经验法则: 值的种类少于几十种用 tags,是标识符(用户 ID、订单号、租户 ID)用 metadata。
8.2 该记哪些 metadata #
这几个是排障时最常用的,建议默认都记上:
config = {
"metadata": {
"user_id": current_user.id, # 出问题能找到是谁遇到的
"session_id": session.id, # 串起同一个会话的多次调用
"release": os.getenv("GIT_SHA"), # 定位是哪个版本引入的
"prompt_version": "v3", # 改了提示词能对比前后
}
}其中 release(版本号)最容易被忽略,也最有用。有它,「这个 bug 是什么时候开始的」就从翻聊天记录变成了一次筛选。
8.3 注意:LangGraph 会自动打标签 #
实际拉回来的 Run 上有一些你没打过的标签:
tags=['seq:step:1']
tags=['graph:step:3']这些是 LangGraph 自动加的,标记节点在图里的执行序号。不要用 seq: 和 graph: 开头的标签名,避免混淆。
9. tracing_context:临时改项目或临时关掉 #
环境变量是全局的。有时你只想改一小段,tracing_context 就是干这个的:
from langsmith import tracing_context
from langsmith import utils
# 临时换项目:这个块里产生的 Run 落到另一个项目
with tracing_context(project_name="ls-course-ch30-tmp"):
handle(" abc ")
# ↑ 落到 ls-course-ch30-tmp,块外的还是原项目
# 临时关掉:函数照常执行,只是不上报
with tracing_context(enabled=False):
print(utils.tracing_is_enabled()) # False
handle(" xyz ") # 正常返回,但不产生 Run
print(utils.tracing_is_enabled()) # True,出块自动恢复实测输出:
函数内可以拿到当前 Run: id=01a065b3-b5aa-73c3-b1d7-1bfbae579bcb
session_name = ls-course-ch30-tmp ← 落到了临时项目
↑ 这次落到了临时项目
enabled=False 块内 tracing_is_enabled() = False
get_current_run_tree() = None(tracing 没开)
↑ 这次完全不上报,但函数照常执行
离开块后 tracing_is_enabled() = True注意第二段里 get_current_run_tree() 变成了 None。这就是第 6 节说的那个判空理由——如果你的代码里有 get_current_run_tree().id 这种写法,一旦有人在外面套了 enabled=False,它就会抛异常。这个 bug 只在关掉 tracing 时出现,很难在开发时发现。
9.1 什么时候用它 #
| 场景 | 写法 |
|---|---|
| 单元测试不想污染项目 | 在 fixture 里套 tracing_context(enabled=False) |
| 批量回填历史数据 | 同上,避免几万条噪音 Run |
| 同一份代码给不同租户 | tracing_context(project_name=f"app-{tenant}") |
| 只想追某一个可疑请求 | 反过来:全局关掉,只给这个请求套 enabled=True |
9.2 关掉 tracing 的三种方式 #
按作用范围从大到小:
# ① 全局:环境变量。适合 CI 环境
os.environ["LANGSMITH_TRACING"] = "false"
# ② 块级:上下文管理器。适合测试 fixture
with tracing_context(enabled=False):
...
# ③ 单次调用:LangChain 的 config
agent.invoke(payload, config={"callbacks": []})第三种要小心:callbacks=[] 会覆盖掉所有回调,包括你自己注册的。想精确关闭 LangSmith 上报,用第二种。
10. 敏感数据脱敏 #
Trace 会完整记录 inputs 和 outputs。这意味着用户的手机号、身份证、订单地址、内部文档原文,全都会上传到 LangSmith 的服务器。合规上这往往是不能接受的。
Client 提供了两个钩子,在上报前改写数据:
from langsmith import Client, traceable
def mask(inputs: dict) -> dict:
"""上报前改写 inputs。注意要返回新字典,别改原对象。"""
out = dict(inputs)
if "phone" in out:
# 138****34 这种打码
out["phone"] = out["phone"][:3] + "****" + out["phone"][-2:]
return out
# hide_inputs / hide_outputs 都接收一个函数,返回改写后的内容
masked_client = Client(
hide_inputs=mask,
hide_outputs=lambda o: {"已隐藏": True}, # 输出干脆整个替换掉
)
# 用 client= 参数指定这个函数用哪个 client 上报
@traceable(client=masked_client)
def query_user(phone: str, name: str) -> dict:
return {"name": name, "balance": 12345}
res = query_user(phone="13800001234", name="张三")
print("函数真实返回:", res)实测:
函数真实返回: {'name': '张三', 'balance': 12345}
上报到 LangSmith 后查回来:
inputs = {'name': '张三', 'phone': '138****34'}
outputs = {'已隐藏': True}函数的真实返回值不受影响,只有上报的副本被改写了。业务逻辑完全不知道脱敏这回事。
10.1 全局生效的写法 #
上面那样每个函数都传 client= 很啰嗦。想让整个应用都脱敏,用环境变量:
# 最粗暴:完全不上报 inputs 和 outputs,只留结构和耗时
LANGSMITH_HIDE_INPUTS=true
LANGSMITH_HIDE_OUTPUTS=true这个开关的代价是 Trace 的排障价值大幅下降——你能看到「调了哪个工具、花了多久」,但看不到「传了什么参数」。
折中方案是写一个统一的脱敏函数,只打掉真正敏感的字段:
import re
# 需要整个隐藏的字段名
SENSITIVE_KEYS = {"password", "token", "api_key", "id_card", "bank_card"}
# 需要打码的模式:手机号、邮箱
PHONE = re.compile(r"1[3-9]\d{9}")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
def redact(obj):
"""递归脱敏。dict / list / str 都能处理。"""
if isinstance(obj, dict):
return {k: ("***" if k.lower() in SENSITIVE_KEYS else redact(v))
for k, v in obj.items()}
if isinstance(obj, list):
return [redact(v) for v in obj]
if isinstance(obj, str):
s = PHONE.sub(lambda m: m.group()[:3] + "****" + m.group()[-2:], obj)
return EMAIL.sub("***@***", s)
return obj
client = Client(hide_inputs=redact, hide_outputs=redact)10.2 三个提醒 #
- 脱敏发生在客户端,数据没离开你的机器就被改写了。这点在合规审查时很重要。
- 别在脱敏函数里抛异常。 它在上报路径上,抛异常会让这条 Run 整个丢失。稳妥的写法是整个函数包一层
try/except兜底返回{}。 - 系统提示词也会被完整上传。 如果提示词里嵌了内部规则、定价策略这类不能外传的内容,
hide_inputs对llm类型的 Run 同样适用,别忘了覆盖。
11. 实用清单 #
接入完成后,建议按这个清单过一遍:
"""放在项目里当作接入自检脚本。"""
import os
from pathlib import Path
from dotenv import load_dotenv, find_dotenv
# ① 显式指定 .env 路径,别依赖查找(第 5 节的坑)
ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / ".env", override=True)
# ② 断言关键变量读到了
print("找到的 .env :", find_dotenv() or "(没找到)")
assert os.getenv("LANGSMITH_API_KEY"), "密钥没读到,检查 .env 位置"
# ③ 确认开关状态
from langsmith import utils
print("tracing 开启 :", utils.tracing_is_enabled())
print("目标项目 :", os.getenv("LANGSMITH_PROJECT", "default"))
# ④ 验证密钥有效(绕开 SDK,直接打)
import requests
r = requests.get(f"{os.getenv('LANGSMITH_ENDPOINT', 'https://api.smith.langchain.com')}/sessions?limit=1",
headers={"x-api-key": os.getenv("LANGSMITH_API_KEY")}, timeout=20)
print("密钥有效 :", r.status_code == 200, f"(HTTP {r.status_code})")
# ⑤ 发一条测试 Trace
from langsmith import traceable
@traceable(name="接入自检")
def smoke(x: int) -> int:
return x * 2
print("测试调用返回 :", smoke(21))
from langsmith import Client
Client().flush()
print("已上报,去界面确认能看到一条名为「接入自检」的 Trace")12. 练习 #
复现 401 那个坑。 把一个能正常工作的脚本复制到
C:\Temp(或/tmp)下运行,观察报错。然后用find_dotenv()确认根因。亲手踩一遍,下次遇到能省几小时。给非 LangChain 代码补上断层。 找你项目里一个「Trace 里耗时对不上」的地方,用
@traceable把中间的普通函数标注出来,看看那段消失的时间去哪了。设计你的 metadata 方案。 按第 8.2 节,列出你的系统排障时最需要的 5 个字段。想一想:如果只能记 3 个,砍掉哪两个?
写一个脱敏函数并测试它。 用第 10.1 节的
redact做起点,加上你业务里的敏感字段。关键是测试它不会抛异常——构造一个含None、嵌套列表、超长字符串的输入试试。验证一个说法。 第 4 节推翻了三个说法,但那是 langsmith 0.12.1 的行为。在你自己的版本上跑一遍那三个实验,看结论是否一致。这个习惯比记住结论本身更有价值。
13. 小结 #
接入本身:
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=lsv2_pt_xxx
LANGSMITH_PROJECT=my-project三行,业务代码不改。
实测推翻的三个说法:
| 说法 | 实际 |
|---|---|
| 项目名必须在 import 前设 | 任何时候都行(但放顶部更易读) |
| 项目要手动建 | 自动创建,约 4 秒可见 |
| 不 flush 会丢 | 正常退出不丢;强杀和常驻服务才需要 |
一个要记住的排障结论:
401 Invalid token也可能是密钥根本没传。load_dotenv()从脚本所在目录往上找.env,不是从工作目录找。
四个工具:
| 需求 | 用什么 |
|---|---|
| 给普通函数加 Trace | @traceable |
| 临时改项目 / 临时关掉 | tracing_context(...) |
| 给单次调用打标记 | config={"run_name", "tags", "metadata"} |
| 上报前脱敏 | Client(hide_inputs=..., hide_outputs=...) |
一条设计原则:
需要分开看的用项目,需要对比着看的用 tags / metadata。
数据现在流起来了。下一章开始真正地读它——从一棵树里找出「哪一步开始不对劲」,走一遍完整的排障流程。