LangSmith 追踪上线前核对:外发内容、脱敏开关与服务中断实测
打开 LangSmith 追踪通常只需要一行 LANGSMITH_TRACING=true。但这一行的含义是:每次调用的输入、输出、工具参数和报错堆栈,都会被一个后台线程批量发往另一个服务。很多团队在上线评审时才意识到这一点,然后只能靠读文档来推断“到底发了什么”。
本页写给准备在生产环境打开追踪的开发者:用一个本地接收端抓取 SDK 实际上传的内容,把常见的脱敏开关逐个测一遍,再测追踪服务出问题时业务会不会受影响。目标不是替你做合规结论,而是让评审会上的每一句话都有可重复的实验支持。
本页没有测试什么
本页没有连接真实的 LangSmith 服务。 实验里的接收端是一个只有标准库的本地程序,只负责接收并记录 SDK 的上传,不做任何存储、鉴权或展示。因此以下内容不在本页范围:服务端的数据保留期与访问控制、数据集与评估器、在线评估、Prompt Hub、OpenTelemetry 导出路径、JavaScript SDK、自托管部署。本页聚焦“离开你进程的数据长什么样、怎么收窄、出故障时会怎样”这一件事。
实验用的邮箱、手机号、类密钥字符串和用户标识全部是占位的假数据,不是任何真实凭据。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.12.12 |
| 包版本 | langsmith==0.14.12、langchain==1.4.7、langchain-core==1.7.0、langchain-openai==1.7.2、langgraph==1.2.17 |
| 被测对象 | 站点仓库 labs/langsmith-trace-egress/ingest_server.py 提供的本地接收端;Agent 由脚本化的模型替身和工具驱动,不联网、不需要密钥 |
| 官方来源 | Prevent logging of sensitive data in traces (opens in a new tab)、Conditional tracing (opens in a new tab)、Set a sampling rate for traces (opens in a new tab)、Trace with LangChain (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16) |
| 实验代码 | 站点仓库 labs/langsmith-trace-egress/,依赖锁定在 labs/requirements/v1.txt |
先说结论
- 默认情况下,输入、输出、工具参数、工具返回值、
config里的metadata都是明文上传的。 一次 Agent 调用产生 7 个 run,metadata会出现在这个 trace 的每一个 run 上,而不只是根 run(实验 1)。 - 隐藏输入输出不等于不外发。
LANGSMITH_HIDE_INPUTS与LANGSMITH_HIDE_OUTPUTS都打开后,邮箱和密钥串消失了,但工具抛出的异常消息和 traceback 仍然带着手机号上传,metadata里的用户标识也还在(实验 2)。 - 匿名化器要用对正则。 常见写法
\b1[3-9]\d{9}\b在中文语境下会漏:Python 的\w包含汉字,“手机号是13800138000”里的数字前面没有单词边界,手机号原样上传。换成(?<!\d)1[3-9]\d{9}(?!\d)后才被替换(实验 2)。 - 匿名化器处理 inputs、outputs 和 error,处理不到
metadata。 个人标识要么不放进metadata,要么再配合hide_metadata(实验 2)。 - API 密钥本身不会外发,但模型端点地址会。 密钥字段被序列化成对环境变量的引用;
base_url出现在序列化内容里,内部网关地址要按“会外发”对待(实验 1)。 - 追踪出问题不会拖慢业务调用,但会拖慢 flush。 6 种接收端状态下,业务调用都在 1 秒内返回正确答案;而 flush 在连接被拒绝或持续 5xx 时超过 5 秒。短生命周期的进程(脚本、CI 任务、Serverless)要把这段时间算进退出预算(实验 3)。
- 采样以整条 trace 为单位。 保留下来的 trace 都是完整的,不会只剩半条;但被采到的 trace 仍然完整外发,采样不能代替脱敏(实验 4)。
实验 0:本地接收端与测试替身
接收端会回答 SDK 的 /info 探测,接受 /runs/multipart 的上传,把每个请求原样记下来,并把同一个 run 的创建与更新部分合并成一条记录。它还可以配置返回状态码和延迟,用来模拟服务端故障:
"""A local LangSmith-compatible ingest endpoint that records what the SDK sends.
It is NOT LangSmith: it accepts the SDK's multipart run uploads, stores them, and answers with a
configurable status code and delay. It lets a lab inspect trace payloads without an account, a
network, or a real API key. Only the standard library is used; the server binds to 127.0.0.1.
"""
import email
import json
import threading
import time
from email import policy
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Run fields that are uploaded as separate multipart parts next to the run JSON itself.
PART_FIELDS = ("inputs", "outputs", "extra", "serialized", "events")
class IngestServer:
def __init__(self, status: int = 200, delay: float = 0.0):
self.status = status
self.delay = delay
self.posts: list[dict] = [] # one entry per upload request
self._lock = threading.Lock()
outer = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # keep stdout clean
pass
def _reply(self, status: int, body: bytes = b"{}", headers: dict | None = None):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def do_GET(self):
# The SDK asks for server capabilities once; an empty config selects its defaults.
if self.path.startswith("/info"):
return self._reply(200, b'{"version": "local-ingest-lab"}')
self._reply(404)
def do_POST(self):
try:
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length)
with outer._lock:
outer.posts.append({"path": self.path, "ctype": self.headers.get("Content-Type", ""), "raw": raw})
if outer.delay:
time.sleep(outer.delay)
headers = {"Retry-After": "0"} if outer.status == 429 else None
self._reply(outer.status, headers=headers)
except (BrokenPipeError, ConnectionResetError):
pass # the client gave up waiting
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.server.server_address[1]}"
def runs(self) -> list[dict]:
"""Decode every upload into one record per run, merging create and update parts."""
merged: dict[str, dict] = {}
with self._lock:
posts = list(self.posts)
for post in posts:
if not post["path"].endswith("/runs/multipart"):
continue
header = b"Content-Type: " + post["ctype"].encode() + b"\r\n\r\n"
message = email.message_from_bytes(header + post["raw"], policy=policy.default)
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition") or ""
_, _, rest = name.partition(".")
run_id, _, field = rest.partition(".")
record = merged.setdefault(run_id, {})
value = json.loads(part.get_content())
if field:
record[field] = value
else:
record.update(value)
return list(merged.values())
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.server.shutdown()
self.server.server_close()common.py 放着被测的 Agent、假敏感数据和读取抓包结果的小工具。MARKERS 是四个会被追踪的假值,实验用“这个值出现在 run 的哪些字段里”来判断某个开关有没有生效:
"""Shared pieces: the agent under test, the sensitive markers, and helpers that read captured runs."""
import json
from langchain.agents import create_agent
from doubles import ScriptedModel, lookup_order
# Fake personal data. Every value is an obvious placeholder, never a real credential.
EMAIL = "[email protected]"
PHONE = "13800138000"
SECRET = "sk-test-0123456789abcdef"
USER_ID = "u-10086"
MARKERS = {
"email address": EMAIL,
"phone number": PHONE,
"key-like string": SECRET,
"metadata user_id": USER_ID,
}
QUESTION = f"我的手机号是{PHONE},邮箱是{EMAIL},密钥是{SECRET}。我的订单到哪了?"
def ask(config: dict | None = None) -> str:
"""Run the agent once and return the final answer."""
agent = create_agent(ScriptedModel(), [lookup_order])
merged = {"metadata": {"user_id": USER_ID}, **(config or {})}
result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}]}, config=merged)
return result["messages"][-1].content
def fields_containing(runs: list[dict], needle: str) -> list[str]:
"""Names of the run fields in which `needle` appears, across all runs."""
found = set()
for run in runs:
for field, value in run.items():
if needle in json.dumps(value, ensure_ascii=False):
found.add(field)
return sorted(found)
def ordered(runs: list[dict]) -> list[dict]:
return sorted(runs, key=lambda run: run.get("dotted_order", ""))
def depth(run: dict, by_id: dict) -> int:
level = 0
parent = run.get("parent_run_id")
while parent:
level += 1
parent = by_id[parent].get("parent_run_id")
return leveldoubles.py 提供脚本化的模型(先调用一次 lookup_order 工具,再基于工具结果回答)、一个正常的工具和一个必定抛异常的工具,异常消息里带着手机号。所有文本都是中文,因为中文环境正是下面实验 2 暴露问题的地方。
实验脚本用 ls.tracing_context(client=..., enabled=True) 把指向本地接收端的 Client 注入进来,而不是依赖 LANGSMITH_TRACING 环境变量。实验运行器会清除所有 LANGSMITH_* 环境变量,保证脚本不会误连真实服务。
实验 1:一次调用到底发出了什么
下面的脚本触发三次追踪:一次 Agent 调用、一次必定失败的工具调用、一次指向无人监听地址的 ChatOpenAI 调用,然后打印抓到的 run 树和几个关键事实:
"""Map what one traced run sends: the run tree, how metadata spreads, and what a model run exposes."""
import json
import os
import socket
os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")
import langsmith as ls
from langchain_openai import ChatOpenAI
from langsmith import Client
from common import EMAIL, PHONE, USER_ID, ask, depth, ordered
from doubles import lookup_invoice
from ingest_server import IngestServer
PLACEHOLDER_KEY = "placeholder-api-key-0001"
def unused_url() -> str:
"""An address nobody listens on, so the model call fails fast without any provider."""
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return f"http://127.0.0.1:{sock.getsockname()[1]}/v1"
with IngestServer() as server:
client = Client(api_url=server.url, api_key="placeholder-not-a-key", info={"version": "local-ingest-lab"})
llm = ChatOpenAI(model="fake-model", base_url=unused_url(), api_key=PLACEHOLDER_KEY, max_retries=0, timeout=2)
with ls.tracing_context(client=client, enabled=True):
ask()
try:
lookup_invoice.invoke({"phone": PHONE})
except ValueError:
pass
try:
llm.invoke(f"hello {EMAIL}")
except Exception:
pass
client.flush()
runs = ordered(server.runs())
uploads = sorted({post["path"] for post in server.posts})
by_id = {run["id"]: run for run in runs}
print(f"upload requests: {len(server.posts)} to {', '.join(uploads)}")
print(f"runs: {len(runs)} in {len({run['trace_id'] for run in runs})} traces\n")
for run in runs:
flag = " <- error" if run.get("error") else ""
print(f"{' ' * depth(run, by_id)}{run['name']} [{run['run_type']}]{flag}")
agent_runs = [run for run in runs if run["trace_id"] == runs[0]["trace_id"]]
carrying = sum(1 for run in agent_runs if (run["extra"].get("metadata") or {}).get("user_id") == USER_ID)
print(f"\nagent trace: runs carrying the config metadata user_id: {carrying} of {len(agent_runs)}")
print(f"extra.runtime keys: {', '.join(sorted(runs[0]['extra']['runtime']))}")
model = next(run for run in runs if run["name"] == "ChatOpenAI")
payload = json.dumps(model, ensure_ascii=False)
secret_ref = model["serialized"]["kwargs"]["openai_api_key"]
print("\nChatOpenAI run")
print(f" api key value in payload: {PLACEHOLDER_KEY in payload}")
print(f" api key field serialized as: {json.dumps(secret_ref)}")
print(f" base url in payload: {'127.0.0.1' in json.dumps(model['serialized'])}")
print(f" invocation_params keys: {', '.join(sorted(model['extra']['invocation_params']))}")
failed = next(run for run in runs if run["name"] == "lookup_invoice")
print("\nfailing tool run")
print(f" error field holds the phone number: {PHONE in failed['error']}")
print(f" error field holds a traceback with local file paths: {'File \"/' in failed['error']}")upload requests: 1 to /runs/multipart
runs: 9 in 3 traces
LangGraph [chain]
model [chain]
ScriptedModel [llm]
tools [chain]
lookup_order [tool]
model [chain]
ScriptedModel [llm]
lookup_invoice [tool] <- error
ChatOpenAI [llm] <- error
agent trace: runs carrying the config metadata user_id: 7 of 7
extra.runtime keys: langchain_core_version, langchain_version, library, library_version, platform, py_implementation, runtime, runtime_version, sdk, sdk_version
ChatOpenAI run
api key value in payload: False
api key field serialized as: {"lc": 1, "type": "secret", "id": ["OPENAI_API_KEY"]}
base url in payload: True
invocation_params keys: _type, model, model_name, stop, stream
failing tool run
error field holds the phone number: True
error field holds a traceback with local file paths: True- 一次调用就是一个 trace,多个 run。 Agent 调用产生 7 个 run(
LangGraph根节点,两轮model节点各含一个llm,tools节点含一个工具)。整批 run 在同一个/runs/multipart请求里上传。 metadata会向下传播。 只在最外层config里传了一次user_id,这个 trace 的 7 个 run 全都带着它。- 每个 run 都附带运行环境信息。
extra.runtime包含 SDK、库、Python 的版本,以及一个平台字符串。这些信息本身敏感度不高,但“系统信息不外发”这类表述就不能写进你的隐私说明了。 - 模型 run:密钥不在,地址在。 密钥字段被序列化为
{"lc": 1, "type": "secret", "id": ["OPENAI_API_KEY"]}这样的引用,真实值没有出现在任何字段里;openai_api_base则以原值出现在序列化内容里。 - 工具异常:消息和 traceback 都在。 失败工具 run 的
error字段里有异常消息,也有带本机文件路径的完整 traceback。
实验 2:脱敏开关,逐个测
masking.py 对同一段业务(一次 Agent 调用加一次失败的工具调用)使用不同的配置,每个配置在独立的子进程里运行,因为 SDK 对多数环境变量只在进程内读取一次。表格的单元格是“这个假值出现在哪些字段”:in 是 inputs,out 是 outputs,meta 是 extra.metadata,err 是 error,- 表示整个载荷里都没有。
"""What does each privacy switch remove from the payload that LangSmith receives?
Every configuration runs in its own child process because the SDK reads most environment variables
once per process. The child traces one agent run plus one failing tool call into a local ingest
server and reports which run fields still contain each fake sensitive value.
"""
import json
import os
import subprocess
import sys
from common import MARKERS
CONFIGS = [
("baseline", {}, {}),
("hide inputs (env)", {"LANGSMITH_HIDE_INPUTS": "true"}, {}),
("hide inputs+outputs (env)", {"LANGSMITH_HIDE_INPUTS": "true", "LANGSMITH_HIDE_OUTPUTS": "true"}, {}),
("hide metadata (env)", {"LANGSMITH_HIDE_METADATA": "true"}, {}),
("anonymizer, \\b phone regex", {}, {"phone_regex": r"\b1[3-9]\d{9}\b"}),
("anonymizer, lookaround regex", {}, {"phone_regex": r"(?<!\d)1[3-9]\d{9}(?!\d)"}),
("lookaround anonymizer + metadata", {"LANGSMITH_HIDE_METADATA": "true"}, {"phone_regex": r"(?<!\d)1[3-9]\d{9}(?!\d)"}),
("omit_traced_runtime_info", {}, {"omit_runtime": True}),
("tracing_context(enabled=False)", {}, {"enabled": False}),
]
SHORT = {"inputs": "in", "outputs": "out", "extra": "meta", "error": "err", "serialized": "ser"}
ORDER = ["inputs", "outputs", "extra", "error", "serialized"]
def child(index: int) -> None:
import langsmith as ls
from langsmith import Client
from langsmith.anonymizer import create_anonymizer
from common import PHONE, ask, fields_containing
from doubles import lookup_invoice
from ingest_server import IngestServer
_, _, options = CONFIGS[index]
patterns = [
{"pattern": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "replace": "<email>"},
{"pattern": options.get("phone_regex", ""), "replace": "<phone>"},
{"pattern": r"sk-[A-Za-z0-9-]{10,}", "replace": "<key>"},
]
with IngestServer() as server:
client = Client(
api_url=server.url,
api_key="placeholder-not-a-key",
info={"version": "local-ingest-lab"},
anonymizer=create_anonymizer(patterns) if "phone_regex" in options else None,
omit_traced_runtime_info=bool(options.get("omit_runtime")),
)
with ls.tracing_context(client=client, enabled=options.get("enabled", True)):
ask()
try:
lookup_invoice.invoke({"phone": PHONE})
except ValueError:
pass
client.flush()
runs = server.runs()
cells = {name: fields_containing(runs, value) for name, value in MARKERS.items()}
runtime = any("runtime" in (run.get("extra") or {}) for run in runs)
print(json.dumps({"runs": len(runs), "cells": cells, "runtime": runtime}))
def cell(fields: list[str]) -> str:
return "+".join(SHORT[f] for f in ORDER if f in fields) or "-"
def main() -> None:
names = list(MARKERS)
print(f"{'configuration':<34}{'runs':>5} " + " ".join(f"{n:<17}" for n in names) + " runtime")
for index, (label, env, _) in enumerate(CONFIGS):
child_env = {k: v for k, v in os.environ.items() if not k.startswith("LANGSMITH_")}
child_env.update(env)
done = subprocess.run(
[sys.executable, __file__, "--child", str(index)], env=child_env, capture_output=True, text=True, timeout=60
)
if done.returncode != 0:
raise SystemExit(f"child failed for {label!r}:\n{done.stderr[-600:]}")
result = json.loads(done.stdout.strip().splitlines()[-1])
row = " ".join(f"{cell(result['cells'][n]):<17}" for n in names)
print(f"{label:<34}{result['runs']:>5} {row} {'yes' if result['runtime'] else 'no'}")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--child":
child(int(sys.argv[2]))
else:
main()configuration runs email address phone number key-like string metadata user_id runtime
baseline 8 in+out in+out+err in+out meta yes
hide inputs (env) 8 out out+err out meta yes
hide inputs+outputs (env) 8 - err - meta yes
hide metadata (env) 8 in+out in+out+err in+out - yes
anonymizer, \b phone regex 8 - in+out+err - meta yes
anonymizer, lookaround regex 8 - - - meta yes
lookaround anonymizer + metadata 8 - - - - yes
omit_traced_runtime_info 8 in+out in+out+err in+out meta yes
tracing_context(enabled=False) 0 - - - - no逐行解读:
- baseline:邮箱和类密钥串出现在 inputs 与 outputs;手机号额外出现在
err;user_id出现在meta。 - hide inputs:输入侧清空了,但模型回复和工具返回里仍然有这些值,所以 outputs 仍然是出口。只隐藏一侧意味着另一侧继续外发。
- hide inputs+outputs:邮箱和密钥串消失,手机号仍在
err,user_id仍在meta。这是最容易被评审漏掉的一行:开了“隐藏输入输出”,异常信息和 metadata 依然完整上传。 - hide metadata:只清掉
meta一列,对 inputs、outputs、error 没有影响。 - anonymizer,
\bphone regex:邮箱和密钥串被替换;手机号在in、out、err里原样保留。原因是用户文本里“手机号是13800138000,”和工具异常里“未找到手机号13800138000对应的发票”,数字都紧挨着汉字,而 Python 的\b把汉字当作单词字符,数字前后不存在边界。 - anonymizer, lookaround regex:用
(?<!\d)…(?!\d)限定“前后不是数字”以后,手机号在 inputs、outputs、error 三处都被替换。meta里的user_id仍在,因为匿名化器不处理 metadata。 - lookaround anonymizer + metadata:匿名化器加
hide_metadata,四类值在载荷里都不再出现。 - omit_traced_runtime_info:本版本里对 LangChain 回调产生的 run 没有效果,
runtime仍然上传,三类值也都还在。该参数不是隐藏内容的开关。 - tracing_context(enabled=False):0 个 run,什么都没有上传。对必须零留存的租户或请求,这是唯一彻底的办法。
几点边界要说清楚:
- 本页的三条正则只是演示,不是完整的个人信息方案。姓名、住址、身份证号、银行卡号等格式需要你自己补充,并用真实格式的假数据验证。
- 匿名化器只改写它处理的字段;官方文档说明匿名化器会先把载荷序列化成 JSON 再处理,复杂正则或大载荷可能带来性能开销。上线前用你的真实载荷大小测一次耗时。
- 官方文档还说明:当
LANGSMITH_HIDE_INPUTS为真时,匿名化器对输入不生效;同理适用于输出。本页没有实测这条交互。
实验 3:追踪服务出问题,业务会受影响吗
outage.py 让接收端扮演六种状态,每次都测业务调用耗时和随后的 client.flush() 耗时。耗时分成 <1s、1-5s、>5s 三档,避免把机器快慢写进断言;uploads 是接收端实际收到的上传请求数;error callback 是 Client(tracing_error_callback=...) 收到的异常类型。
"""What happens to the application when the trace endpoint is unavailable or slow?"""
import socket
import time
import langsmith as ls
from langsmith import Client
from common import ask
from ingest_server import IngestServer
def unused_url() -> str:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return f"http://127.0.0.1:{sock.getsockname()[1]}"
def bucket(seconds: float) -> str:
return "<1s" if seconds < 1 else "1-5s" if seconds <= 5 else ">5s"
SCENARIOS = [
("endpoint healthy", {}),
("connection refused", {"refused": True}),
("HTTP 500 on every upload", {"status": 500}),
("HTTP 429 on every upload", {"status": 429}),
("HTTP 401 (bad key)", {"status": 401}),
("endpoint answers after 3 s", {"delay": 3.0}),
]
print(f"{'scenario':<28}{'answer ok':<11}{'app call':<10}{'flush':<8}{'uploads':<9}error callback")
for label, options in SCENARIOS:
errors: list[str] = []
with IngestServer(status=options.get("status", 200), delay=options.get("delay", 0.0)) as server:
client = Client(
api_url=unused_url() if options.get("refused") else server.url,
api_key="placeholder-not-a-key",
info={"version": "local-ingest-lab"},
tracing_error_callback=lambda exc: errors.append(type(exc).__name__),
)
started = time.perf_counter()
with ls.tracing_context(client=client, enabled=True):
answer = ask()
called = time.perf_counter()
client.flush()
flushed = time.perf_counter()
callback = ", ".join(sorted(set(errors))) or "-"
ok = "yes" if answer.startswith("订单 A-1001") else "NO"
print(f"{label:<28}{ok:<11}{bucket(called - started):<10}{bucket(flushed - called):<8}{len(server.posts):<9}{callback}")scenario answer ok app call flush uploads error callback
endpoint healthy yes <1s <1s 1 -
connection refused yes <1s >5s 0 LangSmithConnectionError
HTTP 500 on every upload yes <1s >5s 12 LangSmithAPIError
HTTP 429 on every upload yes <1s 1-5s 4 LangSmithRateLimitError
HTTP 401 (bad key) yes <1s <1s 1 LangSmithAuthError
endpoint answers after 3 s yes <1s 1-5s 1 -- 业务调用不受影响。 六种状态下,业务调用都在 1 秒内返回,答案正确。追踪由后台线程上传,不在业务调用的路径上。
- flush 会被拖住。 连接被拒绝时 flush 超过 5 秒且一个请求都没送达;持续 500 时同一个批次共发出 12 个上传请求,超过 5 秒;429 共 4 个请求;401 只发 1 个、不重试,立刻结束;响应慢则 flush 等到响应回来。具体秒数随 SDK 版本和网络不同,上表的分档是本版本的结果。
- 谁会调用 flush? 官方文档说明,Python 里的追踪在后台线程完成,进程可能在上传完成前就结束,建议在
finally里调用wait_for_all_tracers()。结合本实验:长驻的 Web 服务几乎不受影响;脚本、批处理、CI 任务和 Serverless 函数在退出前的 flush 可能被追踪服务的故障拖慢数秒,需要为它设总时限预算,或者接受在端点异常时丢弃 trace。 - 别靠日志发现上传失败。
tracing_error_callback在上传失败时收到异常对象,表中依次看到连接错误、API 错误、限流错误和鉴权错误。用它做失败计数和告警,比翻日志可靠。
实验 4:采样
"""Does trace sampling keep or drop whole traces, and does the rate behave as configured?"""
import langsmith as ls
from langsmith import Client
from common import ask
from ingest_server import IngestServer
ASKS = 100
RUNS_PER_TRACE = 7 # one agent run produces seven runs (see egress_map.py)
def trial(rate: float) -> dict:
with IngestServer() as server:
client = Client(
api_url=server.url,
api_key="placeholder-not-a-key",
info={"version": "local-ingest-lab"},
tracing_sampling_rate=rate,
)
with ls.tracing_context(client=client, enabled=True):
for _ in range(ASKS):
ask()
client.flush()
runs = server.runs()
traces: dict[str, int] = {}
for run in runs:
traces[run["trace_id"]] = traces.get(run["trace_id"], 0) + 1
return {"traces": len(traces), "whole": all(count == RUNS_PER_TRACE for count in traces.values())}
print(f"{ASKS} agent calls per setting")
zero = trial(0.0)
print(f"rate 0.0: traces kept == 0: {zero['traces'] == 0}")
full = trial(1.0)
print(f"rate 1.0: traces kept == {ASKS}: {full['traces'] == ASKS}")
half = trial(0.5)
print(f"rate 0.5: traces kept within 25..75: {25 <= half['traces'] <= 75}")
print(f"rate 0.5: every kept trace has all {RUNS_PER_TRACE} runs: {half['whole']}")100 agent calls per setting
rate 0.0: traces kept == 0: True
rate 1.0: traces kept == 100: True
rate 0.5: traces kept within 25..75: True
rate 0.5: every kept trace has all 7 runs: True- 按 trace 采样。 采样率 0.5 时,保留下来的每一个 trace 都含有完整的 7 个 run,没有只剩半条 trace 的情况。
- 采样率是概率,不是配额。 每次运行保留的 trace 数量不同,所以脚本只断言范围;评估用量与成本时按期望值估算,并留出波动。
- 采样不是脱敏。 被采到的 trace 仍然完整外发。官方文档提供全局的
LANGSMITH_TRACING_SAMPLING_RATE环境变量,也支持在不同的Client上设置不同的tracing_sampling_rate。 - 采样是随机的。 出错的请求同样可能被丢弃。需要保证关键流量全量记录时,可以用
tracing_context按条件开关追踪,官方 Conditional tracing 文档给出了做法,本页没有实测。
上线前核对表
| 要核对的点 | 默认行为(本页实测) | 建议动作 | 怎么验证 |
|---|---|---|---|
| 用户输入与模型输出里的个人信息 | 明文,位于 inputs 与 outputs | 用匿名化器(中文友好的正则)覆盖;确实不能外发的租户用 tracing_context(enabled=False) | 把 MARKERS 换成你系统里真实格式的假数据,重跑 masking.py |
| 工具异常 | error 含异常消息和完整 traceback | 匿名化器覆盖 error;不要把个人数据拼进异常消息 | 让一个工具抛错,检查 err 列 |
metadata 与标签 | 一次传入,整个 trace 的所有 run 都带 | 不放个人标识;必须放时配合 hide_metadata | 看 meta 列 |
| 模型端点地址 | 以原值出现在序列化内容里 | 内部网关地址按“会外发”处理;地址里不要带凭据 | egress_map.py 的 base url 一行 |
| 模型 API 密钥 | 不外发,序列化为环境变量引用 | 继续使用环境变量或密钥管理;同时检查密钥没有被拼进提示词或 metadata | egress_map.py 的 api key 一行,再用你的提示词扫描 |
| 运行环境信息 | 总是附带,本版本没有关闭开关 | 接受,并在隐私说明里如实描述 | masking.py 的 runtime 列 |
| 追踪服务故障时的退出时间 | flush 在连接失败或 5xx 时超过 5 秒 | 为 flush 设总时限;用 tracing_error_callback 计数告警 | outage.py |
| 用量与成本 | 默认全量 | 按 trace 采样,关键流量另行保证 | sampling.py |
| 零留存租户或敏感请求 | 默认会外发 | 在请求入口按租户设置 tracing_context(enabled=False) | masking.py 末行 |
把它用在你的系统上
- 复制
ingest_server.py,在预发环境用ls.tracing_context(client=Client(api_url=<本地接收端>, ...))把你自己的应用接到它上面,而不是接到真实服务; - 把
common.py里的MARKERS换成你系统里真实格式的假数据(身份证号、银行卡号、内部工单号等),把doubles.py换成你的真实 Agent; - 跑一遍
masking.py,把表格作为上线评审的附件;每次升级langsmith或langchain-core后重跑; - 隐私说明里的外发清单以这张表为准,不要凭记忆写。
故障树与排障:评审或线上遇到问题时怎么查
| 现象 | 最可能的原因 | 怎么验证 | 处理 |
|---|---|---|---|
| 隐私评审在 trace 里看到手机号 | 正则用了 \b 且数字紧挨汉字;或手机号出现在 error、metadata | 用接收端抓包,逐字段查找该值 | 改用前后断言的正则;覆盖 error;清理 metadata(实验 2) |
| 打开了隐藏输入输出,仍有敏感信息 | 隐藏开关不覆盖 error 和 metadata | 看 masking.py 里 hide inputs+outputs 一行 | 加匿名化器和 hide_metadata(实验 2) |
| 脚本或 CI 任务退出很慢 | flush 在等追踪服务,重试了多次 | 用 outage.py 复现;看 tracing_error_callback 收到的异常 | 给 flush 设总时限;端点异常时接受丢 trace(实验 3) |
| 追踪界面里找不到某次调用 | 上传失败、采样丢弃,或该请求被 tracing_context 关闭 | 先查失败回调计数,再查采样率和请求入口的开关 | 修复上传;关键流量不要随机采样(实验 3、4) |
| 评审要求“不含任何系统信息” | extra.runtime 总是附带 | 看 masking.py 的 runtime 列 | 调整隐私说明的措辞,而不是承诺做不到的事(实验 1、2) |
相关阅读
- 在调用链内用 middleware 处理敏感信息的做法与边界:Agent middleware 与安全护栏;
- 线上问题从症状回溯到原因的入口:LangChain 生产故障诊断中心;
- 新接入一个模型服务时的验收方法,包括密钥和
base_url的处理:OpenAI 兼容模型接入验收; - 模型调用本身的超时、重试和最坏耗时:模型调用的延迟预算;
- 把上线前核对写成可执行的预检:LangChain 生产发布检查表。
来源
- Prevent logging of sensitive data in traces (opens in a new tab):
LANGSMITH_HIDE_INPUTS、LANGSMITH_HIDE_OUTPUTS、hide_metadata、create_anonymizer及其与隐藏开关的交互、性能提示; - Conditional tracing (opens in a new tab):
tracing_context(enabled=...)的优先级与按租户关闭追踪; - Set a sampling rate for traces (opens in a new tab):
LANGSMITH_TRACING_SAMPLING_RATE与按Client设置的采样率; - Trace with LangChain (opens in a new tab):Python 追踪在后台线程完成,退出前使用
wait_for_all_tracers()。
以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/langsmith-trace-egress/ 在上表版本下对本地接收端的实际运行;tracing_error_callback 与 omit_traced_runtime_info 的行为来自对 SDK 参数的实测。