解决方案
LangSmith 追踪核对

LangSmith 追踪上线前核对:外发内容、脱敏开关与服务中断实测

打开 LangSmith 追踪通常只需要一行 LANGSMITH_TRACING=true。但这一行的含义是:每次调用的输入、输出、工具参数和报错堆栈,都会被一个后台线程批量发往另一个服务。很多团队在上线评审时才意识到这一点,然后只能靠读文档来推断“到底发了什么”。

本页写给准备在生产环境打开追踪的开发者:用一个本地接收端抓取 SDK 实际上传的内容,把常见的脱敏开关逐个测一遍,再测追踪服务出问题时业务会不会受影响。目标不是替你做合规结论,而是让评审会上的每一句话都有可重复的实验支持。

本页没有测试什么

本页没有连接真实的 LangSmith 服务。 实验里的接收端是一个只有标准库的本地程序,只负责接收并记录 SDK 的上传,不做任何存储、鉴权或展示。因此以下内容不在本页范围:服务端的数据保留期与访问控制、数据集与评估器、在线评估、Prompt Hub、OpenTelemetry 导出路径、JavaScript SDK、自托管部署。本页聚焦“离开你进程的数据长什么样、怎么收窄、出故障时会怎样”这一件事。

实验用的邮箱、手机号、类密钥字符串和用户标识全部是占位的假数据,不是任何真实凭据。

适用版本与验证信息

项目内容
验证日期2026-10-26
Python3.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

先说结论

  1. 默认情况下,输入、输出、工具参数、工具返回值、config 里的 metadata 都是明文上传的。 一次 Agent 调用产生 7 个 run,metadata 会出现在这个 trace 的每一个 run 上,而不只是根 run(实验 1)。
  2. 隐藏输入输出不等于不外发。 LANGSMITH_HIDE_INPUTS 与 LANGSMITH_HIDE_OUTPUTS 都打开后,邮箱和密钥串消失了,但工具抛出的异常消息和 traceback 仍然带着手机号上传,metadata 里的用户标识也还在(实验 2)。
  3. 匿名化器要用对正则。 常见写法 \b1[3-9]\d{9}\b 在中文语境下会漏:Python 的 \w 包含汉字,“手机号是13800138000”里的数字前面没有单词边界,手机号原样上传。换成 (?<!\d)1[3-9]\d{9}(?!\d) 后才被替换(实验 2)。
  4. 匿名化器处理 inputs、outputs 和 error,处理不到 metadata。 个人标识要么不放进 metadata,要么再配合 hide_metadata(实验 2)。
  5. API 密钥本身不会外发,但模型端点地址会。 密钥字段被序列化成对环境变量的引用;base_url 出现在序列化内容里,内部网关地址要按“会外发”对待(实验 1)。
  6. 追踪出问题不会拖慢业务调用,但会拖慢 flush。 6 种接收端状态下,业务调用都在 1 秒内返回正确答案;而 flush 在连接被拒绝或持续 5xx 时超过 5 秒。短生命周期的进程(脚本、CI 任务、Serverless)要把这段时间算进退出预算(实验 3)。
  7. 采样以整条 trace 为单位。 保留下来的 trace 都是完整的,不会只剩半条;但被采到的 trace 仍然完整外发,采样不能代替脱敏(实验 4)。

实验 0:本地接收端与测试替身

接收端会回答 SDK 的 /info 探测,接受 /runs/multipart 的上传,把每个请求原样记下来,并把同一个 run 的创建与更新部分合并成一条记录。它还可以配置返回状态码和延迟,用来模拟服务端故障:

ingest_server.py
"""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 的哪些字段里”来判断某个开关有没有生效:

common.py
"""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 level

doubles.py 提供脚本化的模型(先调用一次 lookup_order 工具,再基于工具结果回答)、一个正常的工具和一个必定抛异常的工具,异常消息里带着手机号。所有文本都是中文,因为中文环境正是下面实验 2 暴露问题的地方。

实验脚本用 ls.tracing_context(client=..., enabled=True) 把指向本地接收端的 Client 注入进来,而不是依赖 LANGSMITH_TRACING 环境变量。实验运行器会清除所有 LANGSMITH_* 环境变量,保证脚本不会误连真实服务。

实验 1:一次调用到底发出了什么

下面的脚本触发三次追踪:一次 Agent 调用、一次必定失败的工具调用、一次指向无人监听地址的 ChatOpenAI 调用,然后打印抓到的 run 树和几个关键事实:

egress_map.py
"""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,- 表示整个载荷里都没有。

masking.py
"""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, \b phone 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,什么都没有上传。对必须零留存的租户或请求,这是唯一彻底的办法。

几点边界要说清楚:

  1. 本页的三条正则只是演示,不是完整的个人信息方案。姓名、住址、身份证号、银行卡号等格式需要你自己补充,并用真实格式的假数据验证。
  2. 匿名化器只改写它处理的字段;官方文档说明匿名化器会先把载荷序列化成 JSON 再处理,复杂正则或大载荷可能带来性能开销。上线前用你的真实载荷大小测一次耗时。
  3. 官方文档还说明:当 LANGSMITH_HIDE_INPUTS 为真时,匿名化器对输入不生效;同理适用于输出。本页没有实测这条交互。

实验 3:追踪服务出问题,业务会受影响吗

outage.py 让接收端扮演六种状态,每次都测业务调用耗时和随后的 client.flush() 耗时。耗时分成 <1s、1-5s、>5s 三档,避免把机器快慢写进断言;uploads 是接收端实际收到的上传请求数;error callback 是 Client(tracing_error_callback=...) 收到的异常类型。

outage.py
"""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:采样

sampling.py
"""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 密钥不外发,序列化为环境变量引用继续使用环境变量或密钥管理;同时检查密钥没有被拼进提示词或 metadataegress_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 末行

把它用在你的系统上

  1. 复制 ingest_server.py,在预发环境用 ls.tracing_context(client=Client(api_url=<本地接收端>, ...)) 把你自己的应用接到它上面,而不是接到真实服务;
  2. 把 common.py 里的 MARKERS 换成你系统里真实格式的假数据(身份证号、银行卡号、内部工单号等),把 doubles.py 换成你的真实 Agent;
  3. 跑一遍 masking.py,把表格作为上线评审的附件;每次升级 langsmith 或 langchain-core 后重跑;
  4. 隐私说明里的外发清单以这张表为准,不要凭记忆写。

故障树与排障:评审或线上遇到问题时怎么查

现象最可能的原因怎么验证处理
隐私评审在 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)

相关阅读

来源

以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/langsmith-trace-egress/ 在上表版本下对本地接收端的实际运行;tracing_error_callback 与 omit_traced_runtime_info 的行为来自对 SDK 参数的实测。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-26;
  • 局限:本页没有连接真实的 LangSmith 服务,服务端行为未验证;表中的耗时分档和请求数是 langsmith==0.14.12 在本机的结果,其他版本可能不同;
  • 复现:先执行 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab langsmith-trace-egress;
  • 发现错误请按联系我们反馈。