Skip to content
LangSmith 文档版本 跟随上游 源快照 d225f415 更新于 2026-10-18

设置 LLM auth 代理

部署一个基于 Envoy 的 auth 代理,它验证 LangSmith 签名的 JWT 并把 LLM 请求路由到你的上游提供商或网关。

LLM auth 代理让你的组织对从 LangSmith 发起的所有模型调用强制施加它自己的身份验证流程,从而提供商凭据永不暴露给终端用户,且每个请求都可回溯到一个特定主体。

LLM auth 代理是一个基于 Envoy 的组件,运行在你的环境中,位于 LangSmith 与你的上游 LLM 提供商或网关(例如 OpenAI、Anthropic,或像 LiteLLM 这样的内部 LLM 网关)之间。LangSmith 用一个短生命周期的 JWT(JSON Web Token)签名每个请求。代理验证该 JWT,可选地注入提供商凭据或变换请求和响应体,然后把请求转发到上游。它对 SaaS 和 自托管 LangSmith 客户都可用。

INFO

LLM auth 代理需要 LangSmith 企业方案。更多详情,参阅 定价或联系我们的销售团队。

在以下情形使用 LLM auth 代理:

  • 针对你自己的提供商网关为 Playground 或 LLM-as-judge 评估请求做身份验证。
  • 注入提供商专属 API 密钥或 auth 标头而不把它们暴露给终端用户。
  • 变换请求或响应体(例如,在 OpenAI 格式和自定义网关格式之间转换)。

尤其对于 OAuth2 client_credentials,模型配置上的 OAuth 客户端凭据是一个按配置、自助的替代方案,工作区管理员无需搭建 auth 代理就能设置它。路由在配置级互斥——一个启用了 OAuth 的配置不通过 auth 代理。

工作原理

从 LangSmith 的每个请求都经过代理中的以下步骤:

  1. 验证 JWT(签名、签发者、受众)
  2. 调用你的 ext_authz 服务,它接收验证后的 JWT 并返回要作为标头注入的提供商凭据
  3. 可选地调用你的 ext_proc 变换器,它能重写请求和响应体(例如,在 OpenAI 格式和自定义网关格式之间转换)
  4. 用自定义标头(静态或动态)把请求转发到上游提供商

ext_authz 服务和变换器都是客户部署的组件,在你的环境中与代理一起运行。根据你的用例,可以启用其中任一或两者。

Architecture diagram showing LangSmith issuing a signed JWT to the self-hosted auth proxy, which validates the JWT, applies customer-defined auth, and forwards the request to the upstream model provider.Architecture diagram showing LangSmith issuing a signed JWT to the self-hosted auth proxy, which validates the JWT, applies customer-defined auth, and forwards the request to the upstream model provider.

前置条件

  • LangSmith 企业方案(SaaS 或自托管 0.13.33+)
  • 带 Helm 3 的 Kubernetes 集群
  • Envoy v1.37 或更高(Helm chart 默认为 envoyproxy/envoy:v1.37-latest)
  • 你上游 LLM 提供商或网关的 URL(代理将转发请求到的目的地)

NOTE

auth 代理目前支持 Playground、Evals、Fleet、Chat 和 Insights 功能。 Playground 和 Evals 在 v0.13.33+ 可用。Chat 和 Insights 在 v0.13.39+ 可用。

1. 配置 JWT 签名(仅自托管 LangSmith)

对 LangSmith SaaS 跳过这一步。JWT 签名已配好。

生成一对 Ed25519 密钥,用 step CLI(或你偏好的内部流程)。Ed25519 是 LangSmith 用来签名 JWT 的签名算法。私钥签名每个请求;auth 代理只用公钥验证签名。

shell
TMPDIR_KEYS="$(mktemp -d)"
step crypto keypair "$TMPDIR_KEYS/pub.pem" "$TMPDIR_KEYS/priv.pem" \
  --kty OKP --crv Ed25519 --no-password --insecure
PRIV_JWK=$(step crypto key format --jwk --no-password --insecure < "$TMPDIR_KEYS/priv.pem")
SIGNING_JWKS=$(echo "$PRIV_JWK" | jq -c '{keys: [. + {use: "sig", alg: "EdDSA"}]}')
echo "$SIGNING_JWKS"

把 JWKS 存在一个 Kubernetes secret 中:

shell
kubectl create secret generic langsmith-signing-jwks \
  --namespace <namespace> \
  --from-literal=LANGSMITH_SIGNING_JWKS="$SIGNING_JWKS"

JWKS(JSON Web Key Set)是一种发布密钥的标准 JSON 格式。LANGSMITH_SIGNING_JWKS 含 Ed25519 私钥并作为一个 Kubernetes secret 存储。它永不暴露。LangSmith 自动提取对应的公钥并在 /.well-known/jwks.json 上服务它。auth 代理拉取这个公共端点来验证 JWT 签名,而无需私钥。

在你的 LangSmith values.yaml中引用该 secret:

yaml
platformBackend:
  deployment:
    extraEnv:
      - name: LLM_AUTH_PROXY_ISSUER
        value: "langsmith"        # must match jwtIssuer in the auth proxy chart
      - secretRef:
          name: langsmith-signing-jwks

LLM_AUTH_PROXY_ISSUER 设置签名 JWT 中的 iss 声明。用 langsmith 以匹配 SaaS 默认,或用一个自定义标识符如 langsmith:self-hosted:<short_identifier> 以区分你的安装。该值必须匹配 auth 代理 chart 中的 jwtIssuer(见步骤 4)。

2. 为你的组织启用 LLM Auth Proxy

选项 A: 为一个特定组织启用:

在 LangSmith UI 中,导航到 Settings 页面,复制左上角 Organizations 旁的组织 ID。

对你的 LangSmith PostgreSQL 数据库运行以下内容:

sql
UPDATE organizations
SET config = config || '{"can_use_llm_auth_proxy": true}'
WHERE id = '<organization_id>';

选项 B: 为一个安装中的所有组织启用:

把以下内容添加到你的 LangSmith values.yaml 的 commonEnv 中:

yaml
commonEnv:
  - name: DEFAULT_ORG_FEATURE_CAN_USE_LLM_AUTH_PROXY
    value: "true"

NOTE

此设置对 Personal 组织无影响。

3. 在 LangSmith 中配置组织设置

在 LangSmith UI 中,导航到 Settings > General,配置以下内容:

  1. JWT 受众: 代理将验证的 aud 声明值(例如 example-audience)。它必须匹配 auth 代理 chart 中的 jwtAudiences(见步骤 4)。

  2. 启用 LLM auth 代理: 为你的组织切换为开。

  3. 允许的 URL: 控制代理被允许把 JWT 转发到哪些目的地 URL。这防止凭据被转发到非预期的主机。从三个选项中选一个:

    • Allow all(默认):允许 JWT 转发到任何上游 URL。等价于无限制。
    • Block all: 阻止 JWT 转发到所有 URL。
    • Custom: 指定一个允许 URL 模式的显式列表。不接受空字符串和裸 *。当 LLM auth 代理开关为关时,此控件被禁用。
    LLM Auth Proxy settings in LangSmith showing the Enable LLM auth proxy checkbox, JWT audience field, and Allowed URLs radio buttons with Allow all selected.LLM Auth Proxy settings in LangSmith showing the Enable LLM auth proxy checkbox, JWT audience field, and Allowed URLs radio buttons with Allow all selected.

4. 安装 auth 代理 Helm chart

添加 LangChain Helm 仓库:

bash
helm repo add langchain https://langchain-ai.github.io/helm/
helm repo update

创建一个含上游 URL 和 JWT 验证设置的 values.yaml。JWKS 配置有两个选项:

  • jwksUri(推荐): 指向你 LangSmith 实例的 /.well-known/jwks.json 端点。Envoy 自动拉取并缓存公钥,支持无缝密钥轮换。
  • jwksJson(内联): 把 JWKS JSON 直接粘贴进 values.yaml。用它来做测试或 auth 代理对 LangSmith 无出站网络访问的气隙环境。轮换密钥需要一次 chart 更新。只包含公钥组件;省略 d 字段(私钥)。

如果两者都设置,jwksUri 优先。

yaml
authProxy:
  upstream: "https://gateway.example.com"
  jwtIssuer: "langsmith" # must match LLM_AUTH_PROXY_ISSUER in LangSmith values.yaml
  jwtAudiences:
    - "example-audience" # must match the org setting in LangSmith

  # Option A: remote JWKS (recommended for production)
  # Envoy fetches and caches public keys from LangSmith's /.well-known/jwks.json.
  jwksUri: "https://langsmith.example.com/.well-known/jwks.json"       # self-hosted
  # jwksUri: "https://api.smith.langchain.com/.well-known/jwks.json"   # SaaS
  jwksCacheDurationSeconds: 300

  # Option B: inline JWKS (testing or air-gapped environments only)
  # Omit the "d" field (private key); include public key components only.
  # jwksJson: '{"keys": [{"kty": "OKP", "crv": "Ed25519", "x": "<base64url-public-key>", "use": "sig", "alg": "EdDSA"}]}'

安装 chart:

bash
helm install langsmith-auth-proxy langchain/langsmith-auth-proxy \
  --namespace <your-namespace> \
  -f values.yaml

编写一个 ext_authz 服务

当你需要添加、移除或编辑授权标头时用 ext_authz,例如,基于 JWT 中的身份注入一个提供商 API 密钥。你的服务接收验证后的 JWT 以及可选的请求体,并返回要注入上游的标头。这使用 Envoy 的 HTTP ext_authz 过滤器(而非 gRPC)。

在 values.yaml 中启用它:

yaml
authProxy:
  extAuthz:
    enabled: true
    serviceUrl: "http://my-auth-service:8080"
    timeout: "10s"

工作原理

在转发每个请求之前,Envoy 用与原始请求相同的 HTTP 方法在 <serviceUrl>/check<original_path> 调用你的服务。你的服务在 x-langsmith-llm-auth 标头中接收验证后的 JWT。

你的服务返回一个普通 HTTP 响应:

  • 2xx: 允许该请求。任何匹配 allowedUpstreamHeaders 模式的标头(默认:authorization 和 x-*)被注入上游请求。要在转发前剥离 JWT,在你的响应中包含 x-envoy-auth-headers-to-remove: x-langsmith-llm-auth。
  • 非 2xx: 拒绝该请求。状态码和任何匹配 allowedClientHeaders 模式的标头(默认:www-authenticate 和 x-*)被返回给客户端。

部署选项

你的 ext_authz 服务能以两种方式运行:

  • Sidecar: 在代理所在的同一 pod 中运行服务。在 values.yaml 中把容器加到 authProxy.deployment.sidecars 下,任何所需卷加到 authProxy.deployment.volumes 下。用一个 localhost URL,例如 http://localhost:10002。
  • 独立部署: 独立部署该服务并把 extAuthz.serviceUrl 指向它。用集群内 DNS 名,例如 http://my-auth-service.my-namespace.svc.cluster.local:8080,或者如果服务有它自己的 ingress,用一个外部 HTTPS URL。

示例部署

下面的例子是一个最小的 Python ext_authz 服务,它执行一个 OAuth2 客户端凭据令牌交换。在每个请求上,它返回一个缓存的 Authorization 标头,带一个新鲜的访问令牌,在过期前从配置的令牌端点刷新它。完整例子见 chart 仓库中的 e2e/oauth/。

ext-authz-oauth.py
python
"""ext_authz service that performs an OAuth2 client-credentials token exchange.

Runs as a sidecar (or standalone service) alongside the main auth-proxy component.
On each ext_authz check request it returns a cached OAuth access token,
refreshing it from the configured token endpoint when expired.

Environment variables:
  OAUTH_TOKEN_URL    – Token endpoint (e.g. https://login.example.com/oauth/token)
  OAUTH_CLIENT_ID    – Client ID for the credentials grant
  OAUTH_CLIENT_SECRET– Client secret for the credentials grant
  OAUTH_SCOPE        – (optional) Space-separated scopes to request
  LISTEN_PORT        – (optional) Port to listen on, default 10002
"""

from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import os
import sys
import threading
import time
import urllib.request
import urllib.parse

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
TOKEN_URL = os.environ["OAUTH_TOKEN_URL"]
CLIENT_ID = os.environ["OAUTH_CLIENT_ID"]
CLIENT_SECRET = os.environ["OAUTH_CLIENT_SECRET"]
SCOPE = os.environ.get("OAUTH_SCOPE", "")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "10002"))

# Refresh the token this many seconds before it actually expires.
EXPIRY_BUFFER_SECONDS = 30

# ---------------------------------------------------------------------------
# Token cache (thread-safe)
# ---------------------------------------------------------------------------
_lock = threading.Lock()
_cached_token: str | None = None
_token_expiry: float = 0  # epoch seconds

def _fetch_token() -> tuple[str, float]:
    """Perform a client_credentials grant and return (access_token, expiry_epoch)."""
    data = urllib.parse.urlencode({
        "grant_type": "client_credentials",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        **({"scope": SCOPE} if SCOPE else {}),
    }).encode()

    req = urllib.request.Request(
        TOKEN_URL,
        data=data,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=10) as resp:
        body = json.loads(resp.read())

    access_token = body["access_token"]
    expires_in = int(body.get("expires_in", 3600))
    expiry = time.time() + expires_in - EXPIRY_BUFFER_SECONDS
    return access_token, expiry

def get_token() -> str:
    """Return a valid access token, refreshing if necessary."""
    global _cached_token, _token_expiry
    with _lock:
        if _cached_token and time.time() < _token_expiry:
            return _cached_token
    # Fetch outside the lock so other requests aren't blocked on I/O.
    token, expiry = _fetch_token()
    with _lock:
        _cached_token = token
        _token_expiry = expiry
    print(f"Refreshed OAuth token (expires in {int(expiry - time.time())}s)", flush=True)
    return token

# ---------------------------------------------------------------------------
# ext_authz HTTP handler
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
    def do_any(self):
        try:
            token = get_token()
        except Exception as exc:
            print(f"OAuth token fetch failed: {exc}", flush=True)
            self.send_response(500)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"OAuth token exchange failed")
            return

        self.send_response(200)
        # Replace the header name as needed - this header will be forwarded to the upstream LLM provider / gateway.
        self.send_header("Authorization", f"Bearer {token}")
        self.end_headers()

    # Handle every method Envoy might send for ext_authz checks.
    do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = do_any

    def log_message(self, format, *args):
        # Quieter logs — only print errors.
        pass

if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
    print(f"ext-authz-oauth listening on :{LISTEN_PORT}", flush=True)
    print(f"  token_url={TOKEN_URL} client_id=<redacted>", flush=True)
    server.serve_forever()

extAuthz 参数的完整列表,见 Helm chart README。

编写一个 ext_proc 变换器

当你需要重写请求或响应体时用 ext_proc,例如,在 OpenAI 格式和自定义网关格式之间转换,或向请求载荷注入额外字段。这使用 Envoy 的 ext_proc 过滤器。

与 ext_authz(HTTP)不同,ext_proc 使用一个双向 gRPC 流。Envoy 向你的变换器服务每个处理阶段(请求标头、请求体、响应标头、响应体)发一条消息,你的服务为每个阶段回复变更。你的变换器必须实现 envoy.service.ext_proc.v3.ExternalProcessor gRPC 服务。示例 Go 实现见 chart 仓库中的 e2e/transformer/。

何时用 ext_proc 对 ext_authz

能力ext_authzext_proc
修改请求标头是是
修改响应标头否是
修改请求体否是
修改响应体否是
协议HTTPgRPC

如果你只需要注入 auth 标头(例如为 API 密钥)就用 ext_authz。如果你需要重写体就用 ext_proc。两者可同时启用。

在 values.yaml 中启用 ext_proc:

yaml
authProxy:
  transformer:
    enabled: true
    serviceUrl: "grpc://my-transformer:50051"
    timeout: "10s"
    failureModeAllow: false
    processingMode:
      requestHeaderMode: "SEND"
      requestBodyMode: "BUFFERED"
      responseHeaderMode: "SKIP"
      responseBodyMode: "NONE"

设 failureModeAllow: true 以便变换器不可用时放行请求。默认(false)拒绝请求。

处理模式

通过 processingMode 控制哪些阶段被发送到你的变换器。只启用你需要的阶段,因为禁用未用的阶段会降低延迟。

字段选项描述
requestHeaderModeSEND、SKIP、DEFAULT是否转发请求标头。
responseHeaderModeSEND、SKIP、DEFAULT是否转发响应标头。
requestBodyModeNONE、BUFFERED、STREAMED、BUFFERED_PARTIAL如何发送请求体。
responseBodyModeNONE、BUFFERED、STREAMED、BUFFERED_PARTIAL如何发送响应体。
requestTrailerModeSEND、SKIP是否转发请求尾部。
responseTrailerModeSEND、SKIP是否转发响应尾部。
  • 对请求体重写用 BUFFERED:在发送前缓冲整个体,对 JSON 重写最简单。
  • 对流式 LLM 响应体重写用 STREAMED:随块到达就发送,延迟更低但实现更复杂。
  • 用一个 NONE 完全跳过某阶段。

WARNING

在变更体时,你的 ext_proc 服务必须同时通过 HeaderMutation 更新 content-length 标头以匹配新的体大小。Envoy 会拒绝 content-length 与变更后体不匹配的响应。

请求流程

一个启用了 ext_proc 用于标头注入和体重写的例子:

curl -H "X-LangSmith-LLM-Auth: <JWT>" -d '{"model":"gpt-4",...}'
  -> Envoy(:10000)
  -> built-in Envoy JWT filter (validate sig, iss, aud)
  -> `ext_proc` filter -> transformer:50051 (gRPC)
    <- phase 1: request_headers -> mutate headers (inject Authorization)
    <- phase 2: request_body   -> mutate body (rewrite JSON) + update content-length
  -> upstream LLM provider or gateway

示例部署

下面的例子把一个最小 Go 变换器部署为一个 Kubernetes Deployment。它从请求标头读 JWT,注入一个 Authorization 标头,并把请求体从 OpenAI 格式重写为自定义格式。

transformer-configmap.yaml
yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: transformer-source
data:
  main.go: |
    package main

    import (
        "encoding/json"
        "fmt"
        "io"
        "log"
        "net"
        "strings"

        core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
        ext_proc "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
        "google.golang.org/grpc"
    )

    type server struct {
        ext_proc.UnimplementedExternalProcessorServer
    }

    func (s *server) Process(stream ext_proc.ExternalProcessor_ProcessServer) error {
        for {
            req, err := stream.Recv()
            if err == io.EOF {
                return nil
            }
            if err != nil {
                return err
            }

            var resp *ext_proc.ProcessingResponse
            switch v := req.Request.(type) {
            case *ext_proc.ProcessingRequest_RequestHeaders:
                resp = handleRequestHeaders(v.RequestHeaders)
            case *ext_proc.ProcessingRequest_RequestBody:
                resp = handleRequestBody(v.RequestBody)
            default:
                resp = &ext_proc.ProcessingResponse{}
            }

            if err := stream.Send(resp); err != nil {
                return err
            }
        }
    }

    func handleRequestHeaders(headers *ext_proc.HttpHeaders) *ext_proc.ProcessingResponse {
        var jwtValue string
        for _, h := range headers.Headers.Headers {
            if strings.EqualFold(h.Key, "x-langsmith-llm-auth") {
                if len(h.RawValue) > 0 {
                    jwtValue = string(h.RawValue)
                } else {
                    jwtValue = h.Value
                }
                break
            }
        }

        resp := &ext_proc.ProcessingResponse{
            Response: &ext_proc.ProcessingResponse_RequestHeaders{
                RequestHeaders: &ext_proc.HeadersResponse{},
            },
        }

        if jwtValue != "" {
            // TODO: Replace with your auth logic, e.g. exchange JWT for a
            // provider-specific token, call a secrets manager, etc.
            providerKey := "Bearer your-provider-key"

            headerResp := resp.GetRequestHeaders()
            headerResp.Response = &ext_proc.CommonResponse{
                HeaderMutation: &ext_proc.HeaderMutation{
                    SetHeaders: []*core.HeaderValueOption{
                        {
                            Header: &core.HeaderValue{
                                Key:      "Authorization",
                                RawValue: []byte(providerKey),
                            },
                        },
                    },
                },
            }
        }
        return resp
    }

    func handleRequestBody(body *ext_proc.HttpBody) *ext_proc.ProcessingResponse {
        resp := &ext_proc.ProcessingResponse{
            Response: &ext_proc.ProcessingResponse_RequestBody{
                RequestBody: &ext_proc.BodyResponse{},
            },
        }

        var original map[string]interface{}
        if err := json.Unmarshal(body.Body, &original); err != nil {
            log.Printf("Body parse failed, passing through: %v", err)
            return resp
        }

        // TODO: Replace with your transformation logic.
        // This example wraps the OpenAI-format body in a custom envelope.
        transformed := map[string]interface{}{
            "custom_model":    original["model"],
            "custom_messages": original["messages"],
            "metadata":        map[string]string{"source": "langsmith"},
        }

        newBody, err := json.Marshal(transformed)
        if err != nil {
            log.Printf("Body marshal failed, passing through: %v", err)
            return resp
        }

        // IMPORTANT: update content-length to match the new body size.
        bodyResp := resp.GetRequestBody()
        bodyResp.Response = &ext_proc.CommonResponse{
            Status: ext_proc.CommonResponse_CONTINUE_AND_REPLACE,
            HeaderMutation: &ext_proc.HeaderMutation{
                SetHeaders: []*core.HeaderValueOption{
                    {
                        Header: &core.HeaderValue{
                            Key:      "content-length",
                            RawValue: []byte(fmt.Sprintf("%d", len(newBody))),
                        },
                    },
                },
            },
            BodyMutation: &ext_proc.BodyMutation{
                Mutation: &ext_proc.BodyMutation_Body{
                    Body: newBody,
                },
            },
        }
        return resp
    }

    func main() {
        lis, err := net.Listen("tcp", ":50051")
        if err != nil {
            log.Fatalf("failed to listen: %v", err)
        }
        s := grpc.NewServer()
        ext_proc.RegisterExternalProcessorServer(s, &server{})
        log.Println("transformer listening on :50051")
        if err := s.Serve(lis); err != nil {
            log.Fatalf("failed to serve: %v", err)
        }
    }
  go.mod: |
    module transformer

    go 1.23

    require (
        github.com/envoyproxy/go-control-plane/envoy v1.32.4
        google.golang.org/grpc v1.72.1
    )
transformer-deployment.yaml
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: transformer
  labels:
    app: transformer
spec:
  replicas: 1
  selector:
    matchLabels:
      app: transformer
  template:
    metadata:
      labels:
        app: transformer
    spec:
      initContainers:
        - name: build
          image: golang:1.23
          command: ["sh", "-c"]
          args:
            - |
              cp /src/main.go /src/go.mod /build/ &&
              cd /build &&
              go mod tidy &&
              CGO_ENABLED=0 go build -o /build/transformer ./main.go
          volumeMounts:
            - name: source
              mountPath: /src
              readOnly: true
            - name: binary
              mountPath: /build
      containers:
        - name: transformer
          image: gcr.io/distroless/static-debian12:nonroot
          command: ["/app/transformer"]
          ports:
            - containerPort: 50051
          volumeMounts:
            - name: binary
              mountPath: /app
              readOnly: true
      volumes:
        - name: source
          configMap:
            name: transformer-source
        - name: binary
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: transformer
  labels:
    app: transformer
spec:
  selector:
    app: transformer
  ports:
    - port: 50051
      targetPort: 50051
      protocol: TCP

NOTE

对于生产,预构建一个容器镜像而非在 init 容器中编译。示例多阶段构建见 Helm chart 仓库中的 e2e/transformer/Dockerfile。

额外配置

HTTP 代理

Envoy 不理会 HTTP_PROXY、HTTPS_PROXY 或 NO_PROXY 环境变量。显式配置一个 HTTP 代理:

yaml
authProxy:
  httpProxy:
    enabled: true
    host: "proxy.example.com"
    port: 3128
    noProxy:
      - "internal.corp"
      - ".internal.corp"

不带公共 ingress 部署

当 auth 代理没有公共 ingress、只能通过内部 Kubernetes 网络访问时,LangSmith 服务必须被配置为允许向私有 IP 地址的出站请求。没有这些设置,内置的 SSRF 保护会阻止向私有 IP 的请求。

把以下环境变量添加到你的 LangSmith values.yaml:

  • SSRF_ALLOW_K8S_INTERNAL — 在所有发起 LLM 调用的服务上必需。把它加到支持它的服务的 commonEnv,或加到不支持 commonEnv 的服务各自的 extraEnv。
  • SSRF_ALLOW_PRIVATE_IPS_PLAYGROUND — 仅在 playground 服务上必需。把它加到 playground.deployment.extraEnv。
yaml
# Allow all LLM-calling services to reach the auth proxy on private IPs
commonEnv:
  - name: SSRF_ALLOW_K8S_INTERNAL
    value: "true"

# Allow the playground service to reach the auth proxy on private IPs
playground:
  deployment:
    extraEnv:
      - name: SSRF_ALLOW_K8S_INTERNAL
        value: "true"
      - name: SSRF_ALLOW_PRIVATE_IPS_PLAYGROUND
        value: "true"

如果 commonEnv 在你部署中不适用于所有所需服务,就通过每个发起 LLM 调用服务的 extraEnv 单独设置 SSRF_ALLOW_K8S_INTERNAL。

其他选项

ingress、自动伸缩、资源限制和其他配置选项,见 Helm chart README。

TIP

为了生产可靠性,把 authProxy.autoscaling.hpa.minReplicas 至少设为 3。

完整配置例子

yaml
authProxy:
  upstream: "https://gateway.example.com"   # your LLM gateway or provider
  jwtIssuer: "langsmith"                    # must match LLM_AUTH_PROXY_ISSUER on LangSmith
  jwtAudiences:
    - "example-audience"                    # must match org setting in LangSmith

  # Option A: remote JWKS (recommended for production)
  # Envoy fetches and caches public keys from LangSmith's /.well-known/jwks.json endpoint.
  jwksUri: "https://langsmith.example.com/.well-known/jwks.json"   # self-hosted
  # jwksUri: "https://api.smith.langchain.com/.well-known/jwks.json"  # SaaS
  jwksCacheDurationSeconds: 300             # how long Envoy caches the JWKS (default 5 min)

  # Option B: inline JWKS (testing or air-gapped environments only)
  # jwksJson: '{"keys": [...]}'

  # ext_authz: header-only auth logic (include only if needed)
  # Use this to inject, remove, or modify authorization headers.
  # Your service receives an HTTP request at /check with the validated JWT
  # in the x-langsmith-llm-auth header and responds with headers to inject upstream.
  extAuthz:
    enabled: true
    serviceUrl: "http://localhost:10002"    # sidecar URL
    # serviceUrl: "http://ext-authz.<namespace>.svc.cluster.local:10002"  # separate deployment
    sendBody: false                         # set true to include request body

  # transformer: request/response body transformation (include only if needed)
  # Use this when you need to rewrite request or response bodies (e.g. OpenAI -> custom format).
  # Can be enabled alongside ext_authz.
  transformer:
    enabled: true
    serviceUrl: "grpc://transformer.<namespace>.svc.cluster.local:50051"
    timeout: "10s"
    failureModeAllow: false                 # reject if transformer is unavailable
    processingMode:
      requestHeaderMode: "SEND"             # forward request headers (read JWT, inject auth)
      responseHeaderMode: "SKIP"            # skip response headers
      requestBodyMode: "BUFFERED"           # buffer full body for JSON rewriting
      responseBodyMode: "NONE"              # skip response body
      requestTrailerMode: "SKIP"
      responseTrailerMode: "SKIP"

JWT 声明参考

LangSmith 用 Ed25519(EdDSA) 签名 JWT。公钥在 /.well-known/jwks.json 上服务并由代理自动拉取。auth 代理用这些公钥验证签名。

声明描述
iat、exp、jti、nbf标准 JWT 声明(签发时间、过期、JWT ID、生效前)
iss签发者。SaaS 为 langsmith;自托管用 LLM_AUTH_PROXY_ISSUER 设置
aud受众。匹配 LangSmith 组织设置中的 JWT 受众
sub主体标识符(用户 ID、评估器 ID、助手 ID 或 API 密钥 ID)
actor_type以下之一:user、evaluator、agent-builder、insights、polly、api_key:pat(个人访问令牌)或 api_key:service(服务账户密钥)
workspace_id工作区 ID
workspace_name工作区名
organization_id组织 ID
organization_name组织名
request_id请求关联 ID
ls_user_idLangSmith 用户 ID(只要请求有关联用户就存在)

WARNING

用 ls_user_id 来识别终端用户。在 agent 运行上,sub 是助手的 ID 且 actor_type 是 agent-builder,所以两者都不识别那个人。

JWT 在 x-langsmith-llm-auth 请求标头中传递给你的 ext_authz 或变换器服务。

常见问题

auth 代理支持企业代理吗?

支持。通过 values.yaml 中的 httpProxy 部分配置一个 HTTP 代理。详情见 HTTP 代理。

auth 代理支持自定义证书吗?

支持,通过 customCa 用于自定义 CA 证书,通过 mtls 用于双向 TLS。

单个 auth 代理能路由到多个上游 LLM 网关吗?

不能。auth 代理只有一个 upstream 字段。

auth 代理能为多个组织服务吗?

能。多个组织能通过它们在 LangSmith 中的模型配置指向同一个 auth 代理实例。

LangSmith 到 auth 代理的连接能用 HTTP 而非 HTTPS 吗?

能,但仅在自托管中,我们通常建议把 auth 代理放在一个专属 ingress 之后,使通信用 HTTPS。要允许 HTTP,在你的 LangSmith values.yaml 中的 commonEnv 和 playground.deployment.extraEnv 添加 LLM_AUTH_PROXY_ACCEPT_HTTP。 要为 Chat 和 Insights 启用向 auth 代理的 HTTP 流量,在各自的 extraEnv 部分设置这个环境变量:config.polly.agent.extraEnv(用于 Chat,它曾叫 Polly)和 config.insights.agent.extraEnv。

auth 代理不需要公共 ingress 也能工作吗?

能。当 auth 代理只能通过内部 Kubernetes 网络访问(无公共 ingress)时,把 SSRF_ALLOW_K8S_INTERNAL 添加到所有发起 LLM 调用的服务,并把 SSRF_ALLOW_K8S_INTERNAL 和 SSRF_ALLOW_PRIVATE_IPS_PLAYGROUND 都添加到 playground 服务。配置详情见不带公共 ingress 部署。

何时该用 LLM auth 代理,而非模型配置上的 OAuth 客户端凭据?

当身份验证需要超出 OAuth2 client_credentials 的自定义逻辑时,用 LLM auth 代理。例如,把 LangSmith JWT 交换为提供商专属令牌、注入 GCP 或 AWS 身份,或重写请求和响应体。当每个工作区或团队需要对自己到自定义网关的 OAuth2 client_credentials 做自助控制时,用模型配置上的 OAuth 客户端凭据。两者可以在同一组织内共存;路由按配置。

Helm chart 参考

可配置值的完整列表,见 Helm chart README。


BytesRouter 是社区翻译站点,所有译文仅供参考,一切以官方英文原文为准。