跳到主要内容

接口 422、参数 undefined?前后端契约对不上的四个坑

· 阅读需 7 分钟

前后端联调的三种经典场面:POST 请求返回 422;路由页面参数永远是 undefined;SSE 请求状态码 200,消息却静默无响应。症状各不相同,挖到底是同一类根因——前后端对接口的理解不一致。

在为客户构建 AI Agent SaaS 平台时遇到此问题,前后端分别开发,四个坑全踩了一遍,记录定位方法与预防纪律。

TL;DR​

契约不一致有四种形态,对应三类症状:

坑不一致点症状
字段名前端发 name,后端要 label422
字段类型前端发对象数组,后端收字符串数组422
路由参数路由定义 :id,组件取 agentIdundefined,功能静默失效
事件字段后端推 {"content": ...},前端检查 token200,界面无响应

预防只有一个纪律:动手写代码前,先对齐 Schema——字段名、类型、路由参数名,全部以后端 Schema(或双方共同确认的接口文档)为唯一来源。

坑一:字段名不一致 → 422​

最原始的坑。前端 TypeScript 接口定义创建 API Key 的入参用 name + key,后端 Pydantic Schema 期望的是 label + api_key:

// 前端以为的
interface CreateApiKeyInput {
name: string
key: string
}

# 后端定义的
class ApiKeyCreate(BaseModel):
label: str
api_key: str
POST /api/api-keys → 422 Unprocessable Entity

定位靠 422 响应体,别瞎猜。 FastAPI 的 422 会带 detail 数组,逐条写明缺哪个字段、为什么:

[
{ "loc": ["body", "label"], "msg": "Field required", "type": "missing" },
{ "loc": ["body", "api_key"], "msg": "Field required", "type": "missing" }
]

Field required = 请求体里根本没有这个字段 = 字段名对不上。浏览器 Network 面板展开响应,十秒钟定位。

坑二:字段类型不一致 → 422​

字段名对上了,类型也能埋雷。后端 Schema 声明 mcp_tools 是字符串数组,前端却发对象数组(每个对象带 tool_id 和 token_id,业务上确实需要绑定 OAuth Token):

// 前端发的
{ mcp_tools: [{ tool_id: "t1", token_id: "k1" }] }

# 后端收的
mcp_tools: List[str]
PATCH /api/agents/{id} → 422 Unprocessable Entity

这次 detail 里报的是 Input should be a valid string——类型不符。修法在后端 Schema 兼容两种形态(前端数据结构有业务理由,不该硬砍):

from typing import List, Union

class McpToolConfig(BaseModel):
tool_id: str
token_id: str | None = None

mcp_tools: List[Union[str, McpToolConfig]]

Union 让 Schema 同时接受字符串(只引用工具 ID)和对象(带绑定配置),Pydantic 按顺序尝试匹配。这个模式对「接口演进、前端先行」的场景通用:Schema 迁就真实业务形态,而不是反过来逼调用方削足适履。

坑三:useParams 参数名不一致 → undefined​

路由定义和组件取参各写各的,参数名差一个词:

// 路由定义
<Route path="/agents/:id/memory" element={<MemoryPage />} />

// 组件里
const { agentId } = useParams<{ agentId: string }>()
// agentId 永远是 undefined —— 路由里的参数名叫 id,不叫 agentId

页面不报错、请求状态码正常,就是功能静默失效——agentId 是 undefined,后续 API 调用全带上 undefined,或者干脆没发出去。

两条修法:

// 1. 参数名对齐路由定义
const { id } = useParams<{ id: string }>()

// 2. 想用别的变量名,解构重命名
const { id: agentId } = useParams<{ id: string }>()

这个坑的隐蔽性在 useParams<{ agentId: string }> 的泛型参数——TypeScript 不会校验泛型里的键名是否真的存在于路由定义,类型标注给了假的安心。

坑四:事件字段不一致 → 200 但静默失败​

SSE 流式响应里,后端推的事件数据是 {"content": "..."},前端判断逻辑检查的却是 token:

// 前端的事件判断
const isTokenEvent = (d: any) => 'token' in d // 永远 false

// 后端实际推的
data: {"content": "你好"}

网络面板里请求 200、数据流在动,界面上却一个字都不出现——判断条件永远不成立,事件被静默丢弃。这类坑比 422 更难排查:没有报错,只有「功能没发生」。

修法是事件判断对齐后端的实际数据结构:

const isTokenEvent = (d: any) => 'content' in d && !('type' in d)

SSE / WebSocket 这类「一个连接多种事件」的接口,建议在事件数据里带显式的 type 字段做分发,靠「有没有某字段」推断事件类型的写法,契约变更时就是静默失败的温床。

预防:一条纪律​

四个坑的共同起因:前后端各自凭想象写了对接口的那一半。预防不需要工具,就一条纪律——

新接口动手前,先对齐 Schema:字段名、类型、路由参数名、事件数据结构,逐项过。 后端 Pydantic Schema(或 OpenAPI 文档)是唯一来源,前端 TypeScript 接口照着它写;谁要改契约,先改 Schema、双方确认,再动代码。

联调时遇到症状,按这个顺序筛:422 → 展开响应体 detail 看 loc;undefined → 核对路由定义的 :param 名;200 但功能没发生 → 抓一份真实响应,逐字段对前端判断逻辑。

流式接口的服务端另有伴生坑(客户端断开导致的异常与资源泄漏),已在 FastAPI SSE 客户端断开报 CancelledError?生成器必须捕获并 re-raise 一文展开。

注意事项

前端 TypeScript 的接口类型(interface XxxInput)只是编译期的纸面约束,as any、第三方请求封装都能绕过它——类型对得上不代表字段对得上。契约校验的最终依据永远是与后端 Schema 的比对,不是前端类型不报红。

常见问题​

FastAPI 返回 422 Unprocessable Entity 怎么排查?​

422 的响应体里带 detail 数组,逐条写明哪个字段、什么原因——先用浏览器 Network 面板或 curl 展开它,九成的 422 是字段名与后端 Schema 不一致或字段类型不符。请求体里发 name 而后端 Schema 要 label,就是典型的字段名坑。

React Router 的 useParams 为什么取不到值?​

useParams 返回的键名由路由定义里的 :param 决定:路由是 /agents/:id,就只能用 useParams().id 取。解构时想换变量名用重命名语法 const { id: agentId } = useParams()。参数名对不上不会报错,只是安静地给你 undefined。

前后端字段名不一致怎么预防?​

把后端 Schema(Pydantic/Zod 都行)当成契约的唯一来源:新接口动手前先对齐字段名和类型,前端 TypeScript 接口从 Schema 生成或逐字段核对。接口评审时多问一句「请求体长什么样」,比联调时抓包省一个下午。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Clash 能连上但上不了网?hysteria 国际 UDP 链路 QoS 排查

· 阅读需 6 分钟

Clash 显示已连接,浏览器却打不开任何网页;服务端日志里有 client connected,紧接着流内报 timeout: no recent network activity,整条流在 52 秒到几分钟内死亡。服务器各项健康检查全部通过。

在维护自建海外 VPS 时遇到此问题——跨境链路故障,主机无恙,记录完整的分层排查过程。

TL;DR​

认证能过、流内超时、服务器健康检查全绿——问题不在主机,在路径:国际链路对长流量 UDP 五元组做了 QoS,流进入「半死」状态。

  • 立即恢复:重连 Clash(客户端重新握手会换源端口,绕开被标记的五元组),实测九成场景有效
  • 确认定性:journalctl -u hysteria-server 看是否反复出现 client connected → timeout: no recent network activity 的循环
  • 长期缓解思路(概念):服务端端口范围轮换、或下调带宽声明避免触发抑制——按需选型,本文不展开

问题现象​

客户端与服务端两侧的表现拼出完整图景:

客户端:Clash 状态栏显示已连接(UDP 认证通过),但任何网站都打不开,测速无流量。

服务端(journalctl -u hysteria-server):

client connected               ← 认证成功
... (52s ~ 几分钟后)
timeout: no recent network activity ← 流级死亡
client disconnected
client connected ← 客户端自动重连,循环往复

重灾期数据:故障集中时段曾录得单日 94 / 50 / 20 次的重连风暴;一次典型案例是跑了 9 小时的旧会话进入半死状态引发。

排查链:先排除主机,再定性路径​

整条排查的价值在于排除法的顺序——如果跳过主机检查直接怪运营商,结论站不住。

第一层:认证与端口。 日志有 client connected,说明 UDP 包能到服务端、端口放行正常、认证配置正确。链路的前半程(客户端 → 服务器方向)是通的。

第二层:流级超时。 timeout: no recent network activity 是 hysteria 服务端的流保活探测:一条流长时间没有有效报文,判定死亡。认证能过但流立刻饿死——后半程(服务器 → 客户端方向的回包)大概率被丢了。

第三层:主机健康检查(排除主机侧)。 全部通过,主机洗清嫌疑:

检查项结果
出站 TCP(curl 大文件)正常,带宽跑满
网卡丢包 / 错包计数0
conntrack 表无异常表项堆积
TLS 证书有效期正常
内存 / 磁盘正常

第四层:定性。 三层证据合起来指向一个结论:本机收发正常、认证方向正常、唯独长流量流的回程报文被静默丢弃——这是典型的运营商对国际出口 UDP 长流量五元组的 QoS 行为:持续大流量的同一条 UDP 流(相同五元组)被流量识别系统标记,之后该五元组的包被降优或丢弃,流进入「半死」——没断,但再也跑不动。重连后客户端换新的源端口,等于换了一条没被标记的五元组,立即恢复。

故障源 IP 段为广东移动出口(163.179.x),不同运营商/地区的触发阈值与表现会有差异,但「长流量 UDP 五元组 → 半死 → 换端口恢复」的模式是通用的。

处置:重连恢复 + 观察判据​

日常处置(90% 有效):Clash 重连或重启。本质是换源端口绕开被标记的五元组,恢复时间秒级。

处置无效时:再上服务端日志确认模式——journalctl -u hysteria-server 里如果还是 connected → timeout 循环,且换端口无效,就要怀疑更上游的链路问题(服务器机房出口、中间运营商策略变化),那时再查服务器侧。

长期缓解(按需选型,遇到复发再做):两条思路——服务端监听端口范围轮换(让单条五元组活得不够久,不满足被标记的时长条件),或下调带宽相关声明让流量特征不那么「大」。各自有代价,遇到高频复发时再评估。

顺带一提:如果故障发生在 WSL2 环境里,还要先排除本机代理链路的另一类坑(防火墙拦截代理端口),见 WSL2 代理频繁断网?Windows 防火墙拦了代理端口——先把本机因素排干净,再往外定性链路。

注意事项

「服务器健康检查全过」不等于「网络没问题」——健康检查覆盖的是主机与它的直连链路,而 QoS 发生在跨境的中间路径上,任何主机侧工具都测不到。两边结论冲突时(主机全绿但用户没网),优先怀疑路径,这是本案例的核心排查经验。

常见问题​

hysteria 认证成功但上不了网,怎么分层排查?​

按链路三层切:第一层看服务端日志有没有 client connected——有,说明认证和端口都通;第二层看流内报错——出现 timeout: no recent network activity 说明流级死亡;第三层做服务器健康检查(出站 TCP、网卡丢包、conntrack、证书、内存磁盘)——全过就排除主机侧,定性到路径,重点是跨境 UDP 链路被 QoS。

timeout: no recent network activity 是什么意思?​

这是 hysteria 服务端对流级活跃度的探测报错:一条流在一段时间内没有任何有效报文通过,服务端就判定它死亡并关闭。认证握手能过、流却很快死掉,典型成因是路径中间设备(运营商 QoS)把这条 UDP 五元组标记后静默丢包——客户端以为还连着,实际通道已经半死。

UDP 长连接为什么容易被限流?​

运营商对国际出口的长时大流量 UDP 五元组有流量识别与抑制策略:同一条 UDP 流持续高速传输数十分钟到数小时后,容易被降优甚至静默丢弃。这也是为什么同一条隧道「用一会儿没事、挂一天就死」——流量特征越像长连接大流量,越容易触发。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

pandas NaN 让 json.dumps 报错?异常炸在你的 try 之外

· 阅读需 6 分钟

数据管道里一个任务跑到一半戛然而止:日志的 trace 停在中间步骤,没有任何 error 记录,仿佛进程凭空消失。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。分析任务从 pandas 数据起步、以 JSON 落库收尾,NaN 是这条路上最常见的隐形地雷。

问题现象:trace 中断,无 error 记录​

任务状态是失败,但业务日志里找不到任何报错——trace 走到某一步就没了下文。异常确实发生了,只是它炸的位置不在你的 try 覆盖范围内:排查发现是 ti.xcom_push 内部做 JSON 序列化时抛的错,而 xcom_push 这一行在业务 try 块之外(即便包进去,框架内部更深层的序列化点也照样在你的 catch 半径之外)。

真身是这个报错:

ValueError: Out of range float values are not JSON compliant: nan

如果你搜的是「json.dumps NaN 报错」「Out of range float values are not JSON compliant」「任务日志中断无 error」,都是同一类问题。

根因:NaN 是 Python 的合法 float,JSON 却不认识它​

两套规则在这里错位。实测行为矩阵:

import json, math

# NaN 在 Python 世界畅通无阻
isinstance(float('nan'), float) # True —— 合法 float
float('nan') == float('nan') # False —— 连等号都测不出来

# JSON 世界:默认宽松,严格模式直接抛
json.dumps(float('nan')) # 'NaN'(非法 JSON 字面量!)
json.dumps(float('nan'), allow_nan=False) # ValueError: Out of range float values...
json.dumps(math.inf, allow_nan=False) # ValueError(±Inf 同罪)

三个要点:

  • JSON 规范(RFC 8259)的数字语法不含 NaN/Infinity。Python json 模块默认 allow_nan=True,遇到 NaN 输出 NaN 字面量——这是对规范的宽松扩展,产出的字符串下游严格解析器会拒绝。坑分两层:要么现在炸(严格模式),要么埋给下游炸(宽松模式)。
  • NaN 骗过常规判空。它不是 None、不是 0、== 自己都返回 False——if not value 一类的卫语句统统放行,数据一路走到序列化层才爆。
  • 爆点在框架代码里。业务代码把 dict 交给 xcom_push、日志 SDK、HTTP 客户端——序列化发生在这些框架内部,深于你的 try/catch 半径。这解释了「trace 中断且无 error 记录」:异常没进你的日志埋点,直接把 task 掀翻。

解决方案:边界前递归清洗,兜底交给任务级钩子​

两道防线。第一道在数据侧——跨 JSON 边界之前递归清洗,NaN 和 ±Inf 一律转 None:

import math

def json_safe(value):
"""递归把 NaN/±Inf 转成 None,其余原样返回。"""
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
return None
if isinstance(value, dict):
return {k: json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [json_safe(v) for v in value]
return value

import json
payload = json_safe(result_dict)
json.dumps(payload, allow_nan=False) # 现在永不抛

清洗之后仍保留 allow_nan=False——它从「炸雷」变成「哨兵」:万一有漏网的非法值,在自家代码里抛出来,好过流到下游。

第二道在框架侧——给任务挂失败钩子,把 catch 外的异常也落进日志体系(Airflow 场景用 task failure callback 或装饰器包住整个 task callable),保证「trace 中断无 error」变成「trace 末端有 error」。排查存量问题时,去 Airflow 任务日志翻 traceback(容器内 logs/dag_id=.../task_id=.../attempt=N.log)比翻业务日志快。

边界与变体​

  • pandas 自带的 to_json 会在输出层把 NaN 转 null,如果整条链路用它输出,可以不清洗;但数据一旦转成 dict 再走 json.dumps,就得自己清洗——坑出现在「换序列化器」的那一刻。
  • NaN == NaN 为 False,去重、断言、测试里的相等比较都会被它骗;判 NaN 只能用 math.isnan。
  • numpy 数组直接 dumps 也会炸(ndarray 不是 JSON 可序列化类型),先 .tolist();tolist 之后 NaN 还在,清洗逻辑依然需要。
  • 数值语义上 NaN→null 是一次有损转换(「测量失败」变成「没有值」),下游如果依赖这个区分,应另立字段标注,而不是硬转。

注意事项

  • 判 NaN 只用 math.isnan,任何基于 ==、if not x 的判断都不可靠。
  • 清洗放在序列化边界前统一做一次,别在每个调用点各自为战——漏一个调用点就复现一次。
  • 严格模式(allow_nan=False)当哨兵用:清洗后仍报错说明有漏网数据,这是好信号。
  • 框架兜底钩子(failure callback)不是可选品:它能接住你今天想不到的、明天一定出现的「catch 之外的异常」。

常见问题​

json.dumps 遇到 NaN 为什么报 ValueError?​

JSON 规范没有 NaN/Infinity;allow_nan=False 开启严格模式后遇到它们即抛 ValueError: Out of range float values are not JSON compliant。默认 allow_nan=True 会输出非法 JSON 的 NaN 字面量,埋给下游。

pandas 的 NaN 怎么转成 null 再序列化?​

递归遍历数据结构,把 float 类型的 NaN 和 ±Inf 替换为 None 再 dumps;pandas 层可用 df.where(df.notna(), None) 或 to_json(自带 NaN→null)。结论:清洗要在序列化边界前做,别指望 json 模块替你转。

JSON 为什么不支持 NaN 和 Infinity?​

JSON 规范(RFC 8259)的数字语法只覆盖有限数,NaN/Infinity 不是合法值。Python json 默认放行输出了 NaN 字面量,属于对规范的宽松扩展——下游严格解析器会拒绝。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

跨容器连 PostgreSQL 报 fe_sendauth?exec 免密≠TCP 免密

· 阅读需 5 分钟

在 DB 容器所在宿主机上 docker exec 进容器跑 psql,免密直连一切正常;同样的用户名换到另一个容器里走 TCP 连接,直接报 fe_sendauth: no password supplied。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。编排容器里的运维脚本要跨容器访问分析数据库,连接串照搬了 exec 习惯,第一跳就摔在认证上。

问题现象:exec 能连,TCP 报 fe_sendauth: no password supplied​

同一台宿主机、同一个数据库、同一个用户,两种连法两种命运。实测三方对照(Airflow 容器 → PostgreSQL 容器):

连接方式结果
docker exec cclhub-db psql -U postgres(容器内 socket)免密直连成功
跨容器 TCP,DSN 不带密码fe_sendauth: no password supplied
跨容器 TCP,DSN 带密码连接成功

如果你搜的是「fe_sendauth no password supplied」「psql 不带密码报错」「docker 连 postgres 免密失败」,都是这一类。

根因:socket 免密与 TCP 密码认证是两条 pg_hba 路径​

PostgreSQL 的认证由 pg_hba.conf 按连接类型逐条匹配,官方镜像的默认配置对两条路径给的是完全不同的规则:

  • 容器内 docker exec 走 Unix domain socket,对应 local all all trust 一类的规则——信任本地 socket,不问密码;
  • 跨容器走 TCP(host 类型规则),官方镜像默认要求 scram-sha-256 / md5 密码认证。

于是「exec 免密」建立起来的连接习惯,到了 TCP 上就是缺密码——fe_sendauth: no password supplied 的字面意思正是「服务端要密码,客户端一个都没给」。

顺带把两个容易混淆的错误分开:no password supplied 是没带密码,password authentication failed 是带了但错了。前者查连接串里有没有 password,后者查密码对不对——对症的药不同。

解决方案:DSN 带密码,别照搬 exec 的连接习惯​

跨容器脚本统一用带密码的连接串:

postgresql://postgres:<密码>@<db 主机>:5432/<库名>

密码来源推荐直接读 DB 容器的环境变量(官方镜像的 POSTGRES_PASSWORD 就是为初始化设置的),避免二次硬编码:

PW=$(docker exec cclhub-db printenv POSTGRES_PASSWORD)
psql "postgresql://postgres:${PW}@localhost:5432/postgres" -c "SELECT 1"

Python/应用侧同理,DSN 进环境变量管理:

import os
import psycopg

DSN = os.environ["APP_DB_DSN"] # postgresql://user:pass@host:5432/db
with psycopg.connect(DSN) as conn:
conn.execute("SELECT 1")

注意 URL 里的密码若含 @ : / # 等保留字符需要百分号编码——又一个特殊字符咬人的场景,生成数据库密码时规避这类字符能省掉一整类麻烦。

边界与变体​

  • 报错形态随客户端变:libpq 交互式终端(不带 -t 的 psql)会先提示 Password for user postgres: 再因无输入报 fe_sendauth;连接池、GUI 工具(如 pgAdmin)则直接在界面标认证失败——错误文本不同,根因相同。
  • .pgpass 文件与 PGPASSWORD 环境变量是 DSN 之外的两种供密方式,容器场景里 DSN/env 注入比挂 .pgpass 文件更常见。
  • pg_hba 允许改成 trust 让 TCP 也免密——技术上可行,生产环境不要做:任何能触达端口的进程都拿得到无凭据访问。
  • 认证方法版本差异:新版本官方镜像默认 scram-sha-256,老客户端库可能不支持——那是另一类错误(认证方法不支持),与本文的「没带密码」不同。

注意事项

  • 排障先看连接路径:socket 还是 TCP、命中 pg_hba 哪条规则,决定了要不要密码——别把 exec 的行为当成全局行为。
  • 错误语义要分清:no password supplied(没带)与 authentication failed(带错)的修复动作完全不同。
  • DSN 里的密码做特殊字符规避或百分号编码,URL 保留字符会静默改变解析结果。

常见问题​

fe_sendauth: no password supplied 是什么意思?​

服务端 pg_hba.conf 要求密码认证,但客户端连接串里没有提供密码。它不是「密码错误」(那是 authentication failed),是「没带密码」——检查 DSN 是否缺 password 部分。

为什么 docker exec 进容器 psql 不用密码就能连?​

容器内默认走 Unix domain socket,pg_hba 对本地 socket 配置了 trust 免密;跨容器是 TCP 连接,命中要求密码的 host 规则。同一套命令换个连接路径,认证要求完全不同。

容器里脚本连 PostgreSQL 的正确姿势是什么?​

DSN 显式带密码,如 postgresql://user:password@host:5432/db。密码从 DB 容器的 POSTGRES_PASSWORD 环境变量读取注入,不要硬编码,也不要指望 socket 免密在 TCP 上生效。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

PostgreSQL jsonb 字段判空失效?JSON null 不是 SQL NULL

· 阅读需 5 分钟

用 IS NOT NULL 过滤 jsonb 字段的「空值」,结果 JSON null 的行照样通过——数据明明是空的,判断却为真。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。分析结果的决策快照以 jsonb 存储,规则未触发的行写入的是 JSON null,「有没有规则输出」这个判断因此失真。

问题现象:IS NOT NULL 拦不住的「空值」​

字段->'key' IS NOT NULL 对值为 JSON null 的行返回 true——过滤条件形同虚设,本该被排除的行混进了结果集。如果你搜的是「jsonb 判空不生效」「jsonb 空值过滤不掉」「IS NOT NULL 对 json 无效」,都是同一个问题。

根因:JSON null 是合法 jsonb 值,不是 SQL NULL​

PostgreSQL 里 jsonb 有两套「空」:SQL NULL 表示值不存在,JSON null 是一个合法的 jsonb 值。-> 取出一个值为 JSON null 的键,拿回来的是后者——IS NOT NULL 判断的是「拿回来的东西存不存在」,不是「拿回来的是不是有意义的值」。直接看实测矩阵(PostgreSQL 16 容器实测):

表达式结果
('{"a":null}'::jsonb -> 'a') IS NOT NULLtrue
('{"a":1}'::jsonb -> 'b') IS NULLtrue(缺键返回 SQL NULL)
jsonb_typeof('{"a":null}'::jsonb -> 'a')'null'(字符串)
jsonb_typeof('{"a":1}'::jsonb -> 'b')SQL NULL
'{"a":null}'::jsonb ? 'a'true
('{"a":null}'::jsonb ->> 'a') IS NULLtrue

两个最容易踩的组合:

  • IS NOT NULL 分不出「JSON null」和「真值」——第一行,这正是误判的来源。
  • ->> 取文本后判 IS NULL 同样分不出——JSON null 和缺键都变成 SQL NULL,第六行。想用取文本绕过这个坑,会原样掉进去。

解决方案:判键用 ?,判值用 jsonb_typeof​

两件事要在查询里分开表达,用对工具就不会再混:

-- 判「键存在」:值是不是 null 无所谓
SELECT '{"a":null}'::jsonb ? 'a'; -- true

-- 判「有真值」:断言具体 JSON 类型,'null'、缺键一律排除
SELECT jsonb_typeof(config->'rule_output') = 'array'; -- 数组才算
SELECT jsonb_typeof(config->'rule_output') = 'object'; -- 对象才算

-- 找出「写了 JSON null」的行(数据排查用)
SELECT * FROM t WHERE jsonb_typeof(config->'rule_output') = 'null';

我们踩的具体场景:规则引擎在「规则未触发」时往决策快照里写 JSON null,下游用 ->'rule_output' IS NOT NULL 统计「有规则输出的行」,统计口径全错。修复是把断言收紧为 jsonb_typeof(...) = 'array'——只认数组,JSON null、缺键、其他类型一次排除。配套的 JSON null 判定此前用的是 IS NOT NULL,同批修正。

边界与变体:JSON null 不总是错的​

先说清楚:JSON null 本身是合法设计——「键存在但值为空」和「键不存在」是两种业务语义,API 返回 "extra": null 与不返回 extra 键不是一回事。错的不是数据,是拿 IS NOT NULL 去 做「有值」判断。三种意图对应三种写法:

意图写法
键存在(不管值)jsonb ? 'key'
有指定类型的真值jsonb_typeof(x) = 'array' / 'object' / ...
值为 JSON nulljsonb_typeof(x) = 'null'

另一个隐藏差异在更新侧:jsonb_set(config, '{rule_output}', 'null') 会写入 JSON null,jsonb_set(config, '{rule_output}', NULL) 则把整个键删掉——参数给的是 JSON 还是 SQL NULL,语义完全不同。同是「查询结果与预期不符」的排查,跨查询粒度错位导致的悬空引用是另一类高频根因,可以对照着看。

注意事项

  • 存量数据先摸底再改判定:用 jsonb_typeof(...) = 'null' 跑一遍全表,确认 JSON null 行的规模和来源,再决定是修查询还是修写入方。
  • 团队内统一写法:即便某字段当前恒为合法数组、IS NOT NULL 暂时不会出错,也统一用 jsonb_typeof 断言——字段语义一变,宽松写法就是静默错误。
  • ORM/驱动层同理:应用代码里判「JSON 字段非空」时,注意驱动把 JSON null 映射成什么(多数语言映射为语言级 null/None),别把两层空混为一谈。

常见问题​

PostgreSQL jsonb 里 JSON null 和 SQL NULL 有什么区别?​

JSON null 是合法的 jsonb 值,SQL NULL 表示「值不存在」。实测 ('{"a":null}'::jsonb -> 'a') IS NOT NULL 返回 true;jsonb_typeof 对前者返回字符串 'null',对缺键返回 SQL NULL。

为什么 jsonb 字段 IS NOT NULL 过滤不掉空值?​

->'key' 对 JSON null 返回的是合法 jsonb 值而非 SQL NULL,IS NOT NULL 自然为 true。要判「有真值」用 jsonb_typeof(字段->'key') = 'array' 这类类型断言,要判「键存在」用 ? 操作符。

jsonb 怎么判断键存在还是值为 null?​

键存在用 ? 操作符:'{"a":null}'::jsonb ? 'a' 为 true,不受值是否为 null 影响。值为 JSON null 时 jsonb_typeof 返回 'null',缺键时返回 SQL NULL——二者据此区分。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Vite 环境变量读不到?变量必须加 VITE_ 前缀才暴露给前端

· 阅读需 5 分钟

在 Vite 项目的 .env 里写好了 API_URL=http://localhost:3005,前端代码里 import.meta.env.API_URL 打印出来却是 undefined,接口请求全部落到错误地址。

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR​

Vite 只把 VITE_ 前缀的环境变量暴露给客户端代码,这是防止服务端密钥被打进浏览器 bundle 的安全设计。

# ❌ 不会暴露给前端
API_URL=http://localhost:3005

# ✅ 暴露给前端
VITE_API_URL=http://localhost:3005

代码里对应改为 import.meta.env.VITE_API_URL。TypeScript 项目再加一步 env.d.ts 类型声明,补全智能提示。

问题现象​

两个典型表现:

表现一:变量是 undefined

// .env: API_URL=http://localhost:3005
console.log(import.meta.env.API_URL) // undefined
console.log(import.meta.env) // 里面有 BASE_URL、MODE、PROD...,就是没有 API_URL

.env 文件确实被加载了(MODE、PROD 这些内置变量都在),唯独自己写的变量不见——说明不是加载失败,是暴露规则把它拦下了。

表现二:加了前缀但拼错访问名

// .env: VITE_API_URL=...
const url = import.meta.env.VITE_APIURL // undefined —— 大小写必须完全一致

服务端 Node.js 项目里「环境变量读出来是 undefined」另有常见根因(dotenv 加载顺序),前端和后端这两类 undefined 根因不同,别混着排查。

根因:Vite 的暴露规则是一道安全边界​

Vite 的设计:.env 里可能同时存在两类值——前端要用的公开配置(接口地址)和绝不能进浏览器的密钥(数据库连接串、第三方 API key)。如果全部暴露,一次疏忽就把密钥打进了公开的静态产物。

所以 Vite 定了硬规则:只有 VITE_ 前缀的变量会出现在 import.meta.env 里,其余变量只在 vite.config.ts(Node 侧)通过 loadEnv 可见。

暴露的实现方式也值得知道:构建时静态替换。Vite 打包时直接把 import.meta.env.VITE_API_URL 替换成字符串字面量,运行时根本没有「读环境变量」这个动作。两个推论:

  1. 值会明文出现在产物 JS 里——VITE_ 变量天然是公开的
  2. 运行时改环境(改容器 env、改系统变量)不影响已构建的产物——换环境必须重新构建,或把配置改成运行时注入(如 window.__CONFIG__)

解法:加前缀 + 类型声明​

第一步:.env 里的变量加 VITE_ 前缀。 命名保持语义,前缀只是暴露标记:

# .env
VITE_API_URL=http://localhost:3005

第二步:代码统一走 import.meta.env.VITE_XXX。 建议收敛到一个配置模块,别在组件里散着读:

// src/config.ts
export const API_URL = import.meta.env.VITE_API_URL

第三步(TypeScript 项目):src/env.d.ts 补类型声明,拿到完整智能提示:

/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_URL: string
}

interface ImportMeta {
readonly env: ImportMetaEnv
}

开发、生产两套地址用模式文件分开,Vite 按场景自动选:

.env.development    # dev server 用
.env.production # npm run build 用
.env # 两者都加载,放公共配置

改完任何 .env 文件必须重启 dev server——环境变量只在启动时加载一次,热更新不覆盖它们,这是「明明改了却没生效」的第二大来源。

注意事项

VITE_ 变量等于公开信息:值会被明文内联进客户端 bundle,任何人可见。接口地址、功能开关可以放;数据库连接串、第三方密钥绝不放,密钥只走服务端环境变量。前端需要受保护的配置时,由后端接口下发,而不是塞进 .env。

常见问题​

Vite 环境变量配置在哪个文件里?​

项目根目录的 .env 系列:.env 两种场景都加载,.env.development 只在 dev server 生效,.env.production 只在构建时生效。改完任何 .env 文件都必须重启 dev server——Vite 只在启动时加载一次环境变量,热更新不覆盖它们。

为什么 VITE_ 前缀的变量会泄露?不能放密钥吗?​

不能放密钥。VITE_ 变量在构建时被静态内联进客户端 bundle,任何访问页面的人打开源码就能看到明文。前缀机制的设计目的就是把「允许公开的配置」(接口地址、功能开关)和「必须留在服务端的密钥」分开,密钥走后端环境变量。

import.meta.env 和 process.env 在 Vite 里有什么区别?​

浏览器端代码只能用 import.meta.env——Vite 会把 VITE_ 前缀变量静态替换进去;process.env 是 Node.js 的 API,只在 Vite 配置文件(vite.config.ts)和 SSR 场景可用,前端代码里写 process.env.XXX 打包后就是 undefined。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

本地能调通线上 404?Vite Proxy 不参与生产,需要 Nginx 反代

· 阅读需 5 分钟

Vite 项目本地开发接口全通,npm run build 部署到服务器后,页面能打开,但所有 /api 请求返回 404——或者更隐蔽:返回的是 index.html,前端把 HTML 当 JSON 解析直接报错。

在为客户构建 AI Agent SaaS 平台时遇到此问题,前后端分离部署,记录根因与解法。

TL;DR​

vite.config.ts 里的 server.proxy 只属于 dev server。 npm run build 产出纯静态文件,没有任何代理层;生产环境由 Nginx 接住 /api:

location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

加在 SPA fallback(try_files ... /index.html)能命中的范围之外,nginx -t 校验后 nginx -s reload。

问题现象​

两种表现,同一个根因:

表现一:直接 404

GET https://example.com/api/agents  → 404 Not Found

表现二:返回 index.html(更隐蔽)

GET /api/agents  → 200 OK,响应体却是 <!DOCTYPE html>...
前端 JSON.parse 失败:Unexpected token '<'

第二种是 SPA fallback 吃掉了请求:Nginx 找不到 /api/agents 对应的静态文件,try_files $uri /index.html 把它兜底到了前端入口页。状态码还是 200,等前端解析响应时才炸,排查时容易被「请求成功了」迷惑。

根因:proxy 是 dev server 的运行时功能​

vite.config.ts 里的这段配置:

// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': 'http://127.0.0.1:3000',
},
},
})

它生效的场合只有一个:npm run dev 启动的开发服务器。Vite dev server 是一个 Node 进程,server.proxy 是这个进程里的转发中间件——浏览器请求 localhost:5173/api/agents,dev server 收到后转手发给 127.0.0.1:3000,把响应带回来。

npm run build 之后,产物是 dist/ 下的一堆静态文件,浏览器直接从 Nginx 拿文件——这个链路里没有 Vite 进程,server.proxy 配置留在 vite.config.ts 里,根本不随构建走。

所以生产的请求走向是:浏览器 → Nginx → ?。Nginx 按 location 匹配,没有 /api 规则时,静态文件找不到 /api/agents 这个路径,要么 404,要么被 SPA fallback 兜到 index.html——两种表现就是这么来的。

解法:Nginx 承担生产代理​

第一步:server 块加反代 location。

server {
listen 80;
server_name example.com;
root /var/www/app/dist;

# API 反代:前缀匹配,优先于 SPA fallback
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

# SPA fallback:其余路径全给前端入口
location / {
try_files $uri $uri/ /index.html;
}
}

要点两个:

  • proxy_pass 指向后端真实地址端口(示例的 3000 换成你的后端端口);/api 前缀请求会原样转发,后端路由也要带 /api 前缀
  • Nginx 前缀 location 按最长匹配优先,/api 天然赢过 /,两块不用调顺序;但如果你的 API 路径有更深的公共前缀,保证前缀匹配覆盖即可

第二步:验证后生效。

nginx -t          # 语法校验
nginx -s reload # 平滑重载

# 冒烟验证:应返回后端数据而非 HTML
curl -i https://example.com/api/health

curl 看到 JSON 响应、Content-Type: application/json,代理就通了;看到 text/html 说明请求还在被 fallback 接住。

顺带的收益:反代后前端和后端同域,开发时靠 Vite proxy 绕开的跨域问题在生产也自然消失,不需要再配 CORS。

这一篇与 部署后前端没更新?Nginx 缓存与构建产物检查 是部署排查的姊妹篇:那篇管「上线的代码是旧的」,这篇管「接口在生产没有代理层」,两处都查完,部署类 404 基本穷尽。

注意事项

location /api 的路径拼写必须和后端路由前缀完全一致。如果后端路由是 /api/agents 而 Nginx 反代配的是 /apis,或者 proxy_pass 末尾多了一个 /(会触发路径重写,/api/agents 变成 /agents 到达后端),都会得到新的 404。改完先用 curl -i 打一条真实接口确认响应头,再放流量。

常见问题​

生产环境 Nginx 反向代理 /api 怎么配置?​

在 server 块里加一个前缀 location:location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; },然后 nginx -t 校验、nginx -s reload 生效。反代块的 /api 必须与后端路由前缀一致,且优先级高于 SPA 的 fallback location。

Vite 的 proxy 配置怎么确认生效了?​

dev server 启动日志不会打印 proxy 规则,直接验证请求走向:浏览器 Network 面板里请求 URL 仍是 http://localhost:5173/api/xxx 但响应数据来自后端,或用 curl http://localhost:5173/api/xxx 能拿到后端返回,就是生效了。proxy 只在 dev server(默认 5173 端口)上工作。

build 后 Vite 的 proxy 配置会打包进产物吗?​

不会。server.proxy 是 dev server 的运行时中间件,npm run build 产出的是纯静态文件,没有任何代理能力。生产环境的「代理」需要 Nginx 这类反向代理服务器承担,配置写在 Nginx 的 location 里而不是 vite.config.ts。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

LLM Function Calling 静默失败?枚举漂移与 Prompt 膨胀

· 阅读需 13 分钟

在生产环境给记账助手发一句补录指令时,模型明明返回了工具调用、参数也是合法 JSON,功能却没有执行——用户只看到一句「没识别到」。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,说人话就能记,AI 负责抽取金额、类目与账户。排查清楚后发现,这不是一次偶发故障,而是两类都会静默发生的结构性问题,分开写成本文的两个场景。

TL;DR​

  • 场景一:工具定义里的枚举与服务端校验的枚举是两处手写的常量,发生了漂移。模型按「说明书」发了合法值,校验端却拒绝它;又因为数量校验挂在数组层,一个非法候选拖垮整卡。修法是枚举单源引用 + 逐项容错。
  • 场景二:为了减少无效候选,我们在 prompt 里教模型「每个候选必须带全参数」。教学让输出变长,小模型的坏 JSON 率从约 6% 升到 12-28%。修法是撤回教学、校验放宽,缺参交给下游兜底。
  • 两条规则:成对出现的枚举必须同一常量单源;教学只教路由、不教抽参。

先分清两张面:模型看到「定义面」,代码守着「校验面」​

Function calling 里,工具定义与参数校验是两套独立代码,模型只见到前者——所有静默失败都发生在这两套代码的失配处。

模型并不真正执行函数。每次调用,你的服务端把工具定义(JSON Schema 格式)发给模型,模型返回一个工具名加一段参数 JSON,真正执行前由你的代码做校验。这意味着同一个「合法取值集合」至少存在于两个地方:发给模型的定义里,和服务端的校验代码里。

我们说的「静默失败」,指的是这类症状:接口不报错、调用有返回、参数看起来是合法 JSON,但功能就是没执行;用户侧只收到一句兜底话术,换一种说法就是「工具调用没生效」「功能偶发失灵」。错误细节只在服务端日志里,如果没有日志埋点,这类问题可能长期存在而不被发现。

LLM 工具调用两类静默失败的修复前后链路对比

场景一:模型按 Schema 发的合法枚举值,为什么被整卡拒绝?​

当工具定义里的枚举与服务端校验枚举不是同一个常量时,模型按定义面发的「合法值」一定被校验面拒绝,而数组层的数量校验会把单项非法放大成整卡作废。

现象:一条只在日志里可见的失败链​

我们的路由模型有一类「消歧」工具:当用户输入存在歧义时,模型不直接执行,而是调用一个元工具输出 2-4 个候选意图,渲染成可点选的卡片让用户确认。每个候选里有一个 tool 字段,取值必须是真实存在的动作工具名。

某天生产日志里出现一条路由失败:用户说「请缺失日期,批量写入」,模型确实调用了消歧工具,arguments 是合法 JSON——但整张候选卡被判非法,用户收到的是「没识别到」的兜底话术。更迷惑的是对照现象:同一句话,用户在第六轮换了个说法,路由就通过了。这说明不是模型抽风,而是某个特定取值触发的确定性失败。

根因:两处手写的枚举,和挂在数组层的数量校验​

排查后找到两个叠加的成因。

成因一:枚举漂移。 工具定义里,候选的 tool 字段枚举用的是「全量工具名集合」;而服务端校验用的枚举,是从全量集合里剔除了一个仅限图片链路的批量工具后的子集——因为这个工具不该被模型提名,提名它会绕过图片链路的专属契约。问题是这两个集合写在两处、各自维护:

// 定义面:发给模型的 JSON Schema(修复前)
tool: { type: 'string', enum: [...ALL_TOOLS] } // 全集

// 校验面:服务端 zod(修复前,另一处手写)
const ALLOWED = ALL_TOOLS.filter((t) => t !== 'batch_split') // 子集
tool: z.enum(ALLOWED)

模型看到的「说明书」说 batch_split 是合法值,就老老实实地用了它;校验面说不合法。模型按你给的 Schema 行事,却被你的校验拒绝——这不是模型犯错,是两张面在说谎。

成因二:数组级校验的整卡作废。 候选列表的数量约束挂在数组层:

options: z.array(OptionSchema).min(2).max(4)

zod 对数组的校验是每个元素都过、任何一个失败整个数组就失败。于是一个非法候选(比如那个 batch_split)会拖垮整张卡——其余三个完全合法的候选陪葬。上层只留了一句兜底话术,真相只在日志里。

修复:枚举单源,然后逐项容错​

第一步,让两张面引用同一个常量(这个修复只有一行):

const DISAMBIGUABLE_TOOLS = ALL_TOOLS.filter((t) => t !== 'batch_split')

// 定义面
tool: { type: 'string', enum: [...DISAMBIGUABLE_TOOLS] }
// 校验面
tool: z.enum(DISAMBIGUABLE_TOOLS)

第二步,把「一个坏元素废整个数组」改成「坏元素只剔自己」:先用 preprocess 做逐项校验,非法项剔除并记日志,剔完再做数量下限判断:

import { z } from 'zod'

const OptionSchema = z.object({
tool: z.enum(DISAMBIGUABLE_TOOLS),
label: z.string().min(1).max(30),
args: z.record(z.unknown()).optional(),
})

// 逐项容错:形状非法的候选剔除,不拖垮整卡;剔除动作记日志便于观测
function filterOptions(raw: unknown): unknown {
if (!Array.isArray(raw)) return raw
return raw.filter((item) => OptionSchema.safeParse(item).success)
}

const ArgsSchema = z.object({
// min 变成「剔后」下限:剔光才整卡拒绝,走兜底话术
options: z.preprocess(filterOptions, z.array(OptionSchema).min(1).max(4)),
})

这里有个边界要交代:逐项容错会静默丢弃非法候选,所以剔除动作必须记 warn 日志,否则坏候选消失得无声无息,观测面就瞎了。另外 min 的语义从此变成「剔后下限」——全部候选被剔光时才整卡拒绝,那时确实没有可展示的东西,走兜底是合理的。

这个「一个多余字段/取值拖垮整条输出」的问题,和我们之前踩过的 Zod .strict() 校验 LLM 输出静默失败 是同族:对完全受控的客户端输入,严格校验是对的;对模型输出,容错要给到单项粒度。

场景二:加了「参数必填」教学后,坏 JSON 率从 6% 翻到 12-28%​

对负载长度敏感的元工具,强制候选带全参数的教学会让输出变长,直接推高小模型的坏 JSON 率——同一组用例 A/B 实测,从约 6% 升到 12-28%。

出发点合理的教学​

场景一修复后,我们又发现新问题:有些候选上了卡,用户点选后才发现缺少必填参数,只能再追问一轮。于是很自然地加了一条教学——「每个候选的 args 必须带全该工具的必填参数,无法预填完整参数的候选不要提名」——并且在三处同时注入:系统提示的规则、工具定义的参数描述、few-shot 示例的注释。

// few-shot 示例(后来撤回的教学)
{
input: '删掉它',
tool: 'suggest_options',
note: '指代不明 → 消歧;每个候选 args 必须带全该 tool 必填参数(如 delete_record 需 domain),
无法预填完整必填 args 的候选不提名',
}

现象:验证闸三连红​

我们有一条验证闸:固定的一组路由用例,每次改动后批量重跑,失败率超阈值就红灯。教学上线后闸连续三轮失败,失败形态高度一致——全部是消歧工具调用的 JSON 格式错误(截断、多余的闭合括号),不是参数校验失败;错误频次从每轮 2 例涨到 5 例,逐轮递增。

归因:旧构建 A/B 对照,把变量钉死​

一开始的怀疑方向是「教学文案本身误导了模型」。但真要下结论,得做对照:新旧两个构建,同一组 4 句用例,每句各跑 16 次,统计坏 JSON 率。

构建坏 JSON 率
旧构建(无 args 必填教学)约 6%(16 次中 1 次;全量闸上同样偶发 1 例)
新构建(三处 args 必填教学)12-28%

6% 那一例值得说一句:旧构建在全量闸上也会概率性飘红,这是小模型的既有底噪,不是新改动引入的。真正的信号是 6% 到 12-28% 的跳变——教学让每个候选都背上了完整参数,消歧工具的输出显著变长,而小模型在更长输出上的 JSON 格式错误率会放大。这个变量关系,单看一轮红绿是看不出来的。

修复:撤教学,校验放宽,下游兜底​

裁决是三步:

  1. 三处 args 必填教学全部剥离,few-shot 只教路由方向(什么输入该走消歧、候选收敛到哪几个),不教抽参。
  2. 校验放宽为「仅校有 args 的候选」:候选带了 args 就用该工具自身的 schema 预演校验,值非法照旧剔除;args 缺失则容忍上卡。
  3. 缺参交给下游确定性出口兜底:用户点选缺参候选时,服务端返回 422 并附引导文案,原卡保留,用户补一句即可。
// 仅对已给出 args 的候选做同源校验:值非法仍剔,缺失容忍上卡
if (option.args !== undefined && !parseToolCall(option.tool, JSON.stringify(option.args))) {
dropped.push(option.tool) // 剔除 + warn 日志
continue
}

为什么敢容忍缺参上卡?因为下游兜底是确定性的——它不依赖模型自觉,而是服务端代码的固定分支。教学想预防的问题已经被代码接住了,教学本身就只剩成本(更长的输出、更高的坏 JSON 率)。这和思考模型吃满输出预算导致空回复是同一族问题:小模型的结构化输出对负载长度敏感,别的静默坑见 DeepSeek/Qwen 结构化调用返回空的排查。

沉淀成两条规则​

LLM 工具调用的可靠性靠两条规则保障:成对出现的枚举必须同一常量单源引用,prompt 教学只教路由方向不教参数抽取。

规则一:成对出现的枚举,必须同一常量单源引用。 只要一个取值集合同时出现在「发给模型的定义」和「服务端的校验」里,就禁止两处各自手写——从同一个常量展开。这条对枚举之外的任何成对约束(字段列表、数量上限)同样成立。

规则二:教学只教路由,不教抽参;判定教学去留用旧构建对照。 给模型的每一条教学都在为输出加长度,而小模型的格式错误率随输出长度上升。路由方向(什么输入走什么工具)是低成本的引导,值得教;参数完整性这类能被确定性校验接住的问题,交给代码兜底,别用 prompt 教。判断一条教学是否该留,别拿单轮测试的红绿下结论——新旧构建对同一组用例各跑十几轮,看坏 JSON 率的跳变才有说服力。

注意事项

逐项容错不是免费的:剔除动作必须记日志(我们用 warn 级别带上被剔的工具名),否则非法候选静默消失,观测面就瞎了。同理,「剔后数量下限」的语义要在代码注释里写清楚,避免后来者把 min 值当原始数量约束改回去——整卡校验一旦回归,场景一的症状会原样复发。

常见问题​

大模型工具调用失败怎么办?​

先查两类静默失败:一是工具定义里的枚举与服务端校验枚举是否引用同一个常量,漂移时模型按 schema 发的合法值会被整卡拒绝;二是 prompt 是否强制模型输出完整参数,负载变长会推高小模型坏 JSON 率。实测案例中坏 JSON 率从约 6% 升到 12-28%,枚举单源加逐项容错后回落。

function calling 的工作原理是什么?​

模型不真正执行函数:它只输出符合工具 JSON Schema 的工具名和参数 JSON,执行与校验都发生在你的服务端。定义 Schema 与校验 Schema 是两套代码,本文两个场景——枚举漂移导致整卡拒绝、教学加码导致坏 JSON 率从 6% 涨到 12-28%——都发生在这两套代码的失配处。

怎么提高大模型工具调用的成功率?​

三件事:数组类参数用逐项校验,单项非法剔除该项而不是整卡作废;枚举等成对出现的取值集合用同一常量单源引用;prompt 教学只教路由方向不教参数抽取,缺参交给下游确定性校验兜底。同一组用例 A/B 对照实测,这三条能把坏 JSON 率从 12-28% 拉回 6% 基线。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

移动端 vaul 抽屉无法下拉关闭?根挂 overflow 拦截拖拽手势

· 阅读需 7 分钟

在手机上打开筛选抽屉时,内容看得到但滚不动,下拉也关不掉——桌面浏览器里一切正常,只有真机出问题。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,筛选和分类管理都走底部抽屉交互。这个坑我们修了两次:第一次修复后,另一次页面重写凭记忆重排了抽屉 JSX,同一个坑几小时内就回来了。

TL;DR​

  • 根因:DrawerContent 的根元素是 vaul 的拖拽层(负责下拉关闭手势)。根上挂 overflow-y-auto(连同 max-h-[80vh] 这类限高重排)后,浏览器把触摸手势判定为「滚动容器滚动」,vaul 的拖拽监听收不到事件——抽屉既不可滚(视觉上被 max-h 截断)也无法下拉关闭。
  • 解法:滚动下沉内层——根上只留固定区(标题栏),内容包进 flex-1 min-h-0 overflow-y-auto 的内层容器。
  • 元教训:修过的结构性坑,重写页面时必须从模板出发核对,不能凭旧代码记忆重排 JSX。

现象:桌面正常,真机抽屉「钉死」​

抽屉用的是 shadcn/ui 的 Drawer 组件(底层是 vaul ^1.1.2)。故障表现:桌面浏览器里抽屉能滚能关,一切正常;真机上内容区滚不动、下拉关闭也无效,抽屉像钉死了一样。而且「滚不动」和「关不掉」总是同时出现。

这个组合症状指向的不是样式问题,而是手势问题——滚动和拖拽在移动端是同一根手指的同一段触摸动作,归浏览器还是归组件,要看手势落在哪个元素上。

根因:滚动容器和拖拽层抢同一个手势​

vaul 的下拉关闭依赖根元素上的触摸手势监听:手指按住任意位置往下拖,根元素持续收到 touchmove,拖动距离超过阈值就关闭抽屉。

当根元素同时是滚动容器时,移动端浏览器的手势判定会先介入:手指移动被解释为「滚动这个容器」,浏览器消费掉这组触摸事件,vaul 的拖拽监听收不到足够的 touchmove——拖拽判定永远不成立。这就是「滚不动 + 关不掉」成对出现的原因:滚动容器赢了手势,vaul 输了,而内容又因 max-h 被截断无法完整展示。

出问题的结构(回归版本,抽自真实 diff):

{/* 错误:根是拖拽层,却挂了滚动 + 限高 */}
<DrawerContent className="max-h-[80vh] overflow-y-auto px-4 pb-6 text-left">
<DrawerHeader>
<DrawerTitle>筛选</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-4">{/* 内容 */}</div>
<DrawerFooter>{/* 操作按钮 */}</DrawerFooter>
</DrawerContent>

这个写法在桌面浏览器完全正常——鼠标滚轮直接驱动滚动容器,vaul 的下拉关闭靠把手(handle)仍可用。所以问题极易在开发阶段漏掉,直到真机走查才暴露。

修复:滚动下沉内层,根只留固定区​

正确结构是把滚动职责从根上摘掉,交给内层容器;标题栏留在根上固定:

{/* 正确:根无 overflow,滚动下沉内层 */}
<DrawerContent className="text-left">
<DrawerHeader>
<DrawerTitle>筛选</DrawerTitle>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-6">
{/* 内容 + Footer 都在这里滚 */}
</div>
</DrawerContent>

三个关键点:

  1. 根上零 overflow、零 max-h——拖拽手势畅通,任意位置下拉都能关闭。
  2. 内层 flex-1 min-h-0——flex 子项默认 min-height:auto,不压到零的话内容会把抽屉撑开、内层根本不产生滚动条;min-h-0 是滚动真正生效的前提。
  3. 标题栏留在根上——视觉上标题固定、内容滚动,交互上把手和标题区的下拉是「关闭」,内容区的滑动是「滚动」,两个手势各归其主。

修复后用 Playwright 手势验证了三件事:根元素 overflowY === 'visible'、内层 scrollTop 可独立滚动、模拟拖拽下拉能关闭抽屉。三条全过,桌面回归无影响。

为什么会回归:凭记忆重排 JSX​

更值得写下来的是这个坑的回归过程。第一次修复(commit a182098,2026-09-23 08:55)解决了「移动端不可滚」;之后一次页面重写,写的时候没有对照修复记录,凭对旧代码的记忆重排了抽屉 JSX——max-h 和 overflow-y-auto 又回到了根上。同日 12:10,第二次修复(f1097b2)落地,和第一次相隔不到 4 小时。

结构性的坑(DOM 层级、手势归属、滚动容器位置)和普通逻辑 bug 不同:它不是「记住结论」就能防住的,因为重写代码的人未必是踩过坑的人,记忆也未必是自己的。可靠的做法是把正确结构沉淀成可复制的模板,规则一句话——「DrawerContent 根是 vaul 拖拽层,禁挂 overflow/max-h,滚动下沉内层」——重写时照模板复制,写完跑一次手势断言。

注意事项

这套「滚动下沉内层」不只适用于 vaul:任何「根元素承载拖拽手势」的组件(各类 bottom sheet、可拖拽 Dialog)都遵循同一规律——手势层和滚动层必须分层。另外 min-h-0 在 flex 列布局里是滚动生效的隐性前提,漏掉它时症状是「内层不出现滚动条、抽屉被撑高」,和根挂 overflow 的症状不同,排查时先确认滚动容器本身能不能滚,再往前追手势归属。

常见问题​

移动端底部抽屉无法下拉关闭怎么办?​

先检查 DrawerContent 根元素是否挂了 overflow-y-auto 或 max-h——DrawerContent 的根是 vaul 的拖拽层,根上的滚动容器会把触摸手势判定为滚动,vaul 收不到拖拽事件。把滚动下沉到内层容器(flex-1 min-h-0 overflow-y-auto),标题栏留在根上固定。

底部弹窗能打开但内容滚不动,是怎么回事?​

「滚不动」和「拉不下来关闭」往往是同一个根因的两个症状:滚动容器放在了组件根上,触摸移动被滚动消费,vaul 的拖拽手势被拦截。滚动下沉内层后两个症状同时消失——内容区滑动是滚动,把手或标题区下拉是关闭。

vaul 的 DrawerContent 上为什么不能放 overflow-y-auto?​

DrawerContent 根元素绑定着 vaul 的拖拽手势监听,负责任意位置下拉关闭;根上放 overflow-y-auto 后,浏览器优先把手势判定为容器滚动,touchmove 事件到不了 vaul 的监听层。这不是 vaul 的 bug,是滚动容器和手势层抢同一个手势的归属。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

微信小程序识图全失败?wx Promise 包装的 [object Object] 坑

· 阅读需 7 分钟

在小程序里点识图按钮上传一张图片时,无论 devtools 还是真机、无论传什么图,识别全部失败——服务端只回一句「图片识别失败,请稍后重试」。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,拍照识图是它的记账入口之一。这个坑的麻烦不在修(一行的事),而在定位:每一层报错都在说谎。

TL;DR​

  • 根因:自己写的 wx API Promise 包装器用 success: resolve 直接把回调结果 resolve 了——wx.readFile 的成功结果是 { data, errMsg } 整体,不是 data 本身。下游 String(b64) 把整个对象转成 "[object Object]",剥空白后剩 14 字符伪 base64,解码出 9 字节固定垃圾发给了服务端。
  • 修法:resolve 之后点属性取值(res.data),并对非字符串抛错防御。
  • 定位经验:通用报错体(invalid params)不泄漏来源时,尽早加发送侧形状诊断日志(长度/字符集/魔数,零隐私内容),胜过反复假设实验。

现象:100% 失败,且与图片内容无关​

失败模式是 100% 必现且与图片内容完全无关——这指向发送侧的数据本身,而不是识别能力。

识图按钮流的链路是:选图 → 压缩 → 读成 base64 → 发给服务端 → 服务端转交视觉模型。故障表现非常整齐:devtools 和真机一致、每一次都失败。服务端日志里只有视觉网关的一条 400:

POST /vision  400  {"error": "invalid params"}

invalid params 是个通用错误体——它不告诉你哪个参数、错成什么样。而我们的路由层把网关失败统一包装成 503「图片识别失败,请稍后重试」,用户看到的和日志里的信息一样少。

排查弯路:两个被证伪的假设​

通用报错体会把排查引向「参数格式规范」方向的猜测——本文的两段弯路都源于此。

假设一:mime 类型不标准。 我们最初怀疑压缩产物用了非标的 image/jpg(标准写法是 image/jpeg)。对着网关直接实测三种 mime,全部返回 200——证伪。图片本身和它的描述信息都没问题,问题在更下游。

假设二:base64 折行。 第二个假设是 wx.readFile 产出的 base64 带换行符,某些网关不接受。我们往测试请求里注入 \n 复现出了同一个错误体——注意,这是个假阳性:通用报错意味着任何非法 payload 都长一个样,注入 \n 复现成功根本不能证明线上就是折行问题。我们还是上了双端剥空白(.replace(/\s+/g, '')),上线后复测——依然全挂。假设二证伪,但剥空白的防御代码留了下来(它本身无害)。

两轮假设都错,因为它们都在「猜参数格式」,而真正的故障模式是发送的东西根本不是图片。

根因:包装器 resolve 了整个回调结果对象​

wx.readFile 的 success 回调结果是整体一个对象 { data, errMsg },包装器把它整个 resolve 了下去——下游拿到的从来不是 base64。

假设耗尽后换了打法:在服务端失败路径加形状诊断日志——只记 payload 的元数据(长度、mime、空白、字符集、解码后字节数、魔数),不记内容,零隐私风险。下一轮复测,日志直接暴露真相:

b64Len: 14   charsetOk: false   decodedBytes: 9   magicHex: a1b8de72

一张图的 base64 至少几万字符,这里只有 14;解码出 9 字节固定垃圾,每次请求的魔数都是 a1b8de72。再对齐一个关键线索:两次不同图片的请求 payload 逐字节相同——固定串,不是真实图片。14 字符的固定串是什么?"[object Object]" 剥掉空格后的 "[objectObject]",正好 14 个字符。

回到代码,链路每一环都「看起来对」:

// 通用包装器:把回调风格 API 转成 Promise
const p = (fn) => (opts) =>
new Promise((resolve, reject) => fn({ ...opts, success: resolve, fail: reject }))

// 读文件成 base64(修复前)
const readFileBase64 = (filePath) =>
p(wx.getFileSystemManager().readFile.bind(wx.getFileSystemManager()))({
filePath,
encoding: 'base64',
}).then((b64) => String(b64))

wx.readFile 的 success 回调结果是整体一个对象 { data, errMsg }。包装器 success: resolve 把这整个对象 resolve 了下去;.then((b64) => String(b64)) 拿到的是对象,String() 把它转成 "[object Object]"——全程零报错。剥空白、编码、发送,一路畅通,直到网关用一句通用 400 把它拒收。

Node 的 base64 解码还会忽略非法字符([ 和 ]),于是 12 个合法 base64 字符解出 9 字节垃圾——和诊断日志逐字吻合。

修复:点属性取值 + 抛错防御​

修复就两件事:resolve 之后的对象点属性取值(res.data),取出的值不是非空字符串就直接抛错。


```js
const readFileBase64 = (filePath) =>
p(wx.getFileSystemManager().readFile.bind(wx.getFileSystemManager()))({
filePath,
encoding: 'base64',
}).then((res) => {
const data = res && res.data
if (typeof data !== 'string' || !data) throw new Error('图片读取失败')
return data
})

抛错防御的价值在坏数据出现的瞬间就体现:前端拿到明确的错误信息,而不是千里之外一句通用 400——坏数据死在源头,每一层都省一次猜谜。

修复本身很小,commit 连注释一共 +8/-1 行。修完复测,devtools 与真机识图全部恢复,与图片内容无关的必现故障归零。

注意事项

写 wx API 的 Promise 包装器时,resolve 的对象必须点属性取值——不同 API 的回调结果形状不同:readFile 是 .data、chooseMedia 是 .tempFiles、compressImage 是 .tempFilePath,逐个确认,不要写一个「通用 then」假设形状。String(某对象) 永远静默产出 "[object Object]",不抛错、不告警;对通用 invalid params 类报错,反复假设实验的成本远高于一条发送侧形状日志(只记长度/字符集/魔数等元数据,零隐私内容)。

常见问题​

小程序图片识别失败怎么办?​

先确认失败是不是 100% 必现且与图片内容无关——是的话大概率是发送侧数据本身错了,不是识别能力问题。给发送侧加形状诊断日志(payload 长度、字符集、解码后字节数、魔数,零隐私内容),本案例靠它定位到 base64 实为 14 字符垃圾串。

小程序图片识别失败是怎么回事?​

最隐蔽的一类是数据在发送前就被静默替换:wx API 的 Promise 包装器用 success: resolve 会把整个回调结果对象 { data, errMsg } 当值传下去,String() 化成 "[object Object]"、剥空白后剩 14 字符伪 base64,解码只有 9 字节固定垃圾,服务端只能报 400。

wx.readFile 读出来的数据不对怎么办?​

检查你的 Promise 包装器:回调成功结果要取 res.data 再往下传,不能把整个对象 String() 硬转。同时加一道防御——取出的值不是非空字符串就直接抛错,让坏数据死在源头,比到服务端再看通用报错快得多。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询