一、进场

昨天,我想给在场AI加一个功能:让用户能上传图片,以便更全面地了解用户关系中的另一方。

我觉得很简单。H5页面上加一个上传按钮,用户选图,传给AI,AI能看图说话。这样用户就可以分享与他人聊天的记录,AI能真实了解用户实际的关系场。

我进场了。

第一轮,加按钮,调接口,报错。

第二轮,换方式,改参数,报错。

第三轮,换库,重写逻辑,还是报错。

我有点烦躁。代码看起来没问题,但就是跑不通。我开始怀疑是不是自己水平不行。

二、看见

然后我停下来。

我看见了三件事。

我看见的第一件事是:我犯了一个更严重的错误——我在活版本上直接改代码。 为了调试图片上传,我直接在正在运行的版本上修改,结果改出了新问题:AI开始自动跳出、频繁刷新、正常聊天都断了。我为了加一个功能,把原本能用的主程序搞崩了。

我看见的第二件事是:我的验证方式太慢了。 每次改完代码,我都要部署到线上,然后用手机打开页面,点上传按钮,看有没有报错。如果报错,截图,发给AI,等AI改完,再部署,再测试。一个循环要6-10分钟。一个晚上下来,我大部分时间都在等部署、等测试,而不是在真正解决问题。

我看见的第三件事是:我可能误判了问题的根源。 我反复调不通,开始怀疑是微信未认证服务号的H5页面限制了图片上传。但这个判断,是我在信息不全的情况下做出的——我还没有真正追查到根因,就先给它判了死刑。

三、六个真实的异常:一个接一个爬出来

本地测试环境建起来、日志加上之后,真正的排查才开始。我本以为"调不通"是一件事,后来发现是六件不同的事,一层压一层。下面这六个,是我踩得最狠、也最想留给后来人的——它们不只属于"做 AI 陪伴产品",任何人在调网页、调接口、调第三方服务时,大概率都会撞上类似的。

异常一:按钮点了没反应——微信把"脚本代点"的文件选择拦了。 我最早用 <div onclick> 加 JS 调 input.click() 来触发上传,结果在微信/iOS 里点按钮,文件框根本不弹。排查后才明白:文件选择这类敏感能力,浏览器只允许"用户真实手势"触发,脚本代点会被安全策略直接拦掉。改回原生 <label for="imgInput"> 包裹 file input、由浏览器原生处理,才通。启示:凡是涉及文件、摄像头、定位的触发,别用 JS 代点,老老实实用原生控件。

异常二:换回原生 label 了,图还是进不来——FileList 是"活引用"。 改成 <label for> 后选了图,聊天框里却不显示、AI 也收不到。加日志一看,处理函数拿到的是 0 个文件。根因:我在 change 事件里先 e.target.value='' 清空,却没先复制——FileList 是"活引用",清空 value 会把它同步清空,传给函数的已经是空的。先 Array.from(e.target.files) 复制出来、再清空,才解决。启示:DOM 里 FileList、NodeList 这类对象是"活的",操作前先拷贝,别在清空/移除前还指望它还有内容。

异常三:公开版"点哪都弹图片"——透明输入框铺满了全屏。 部署后,公开版(不是测试版)整个底部点哪都弹图片选择,输入框、发送键、语音键全被盖死。问题只在 /test/ 验过测试版、没暴露公开版。逐层查 CSS,发现透明 file input 用了 position:absolute; inset:0,但父级 <label class="b img"> 没设 position:relative,于是 input 向上锚定到视口级祖先、铺满全屏。给父级加 position:relative 做包含块(后来又进一步改成"视觉隐藏但可点击"的标准写法)才根除。启示:绝对定位前先确认"包含块"是谁;一个没设定位上下文的祖先,会把你的浮层撑到天边。

异常四:明明修好了,线上还是坏的——缓存假象。 我把修复部署上去,用 curl 抓 HTML 确认新规则已经在里面,可手机微信里打开还是"点哪都弹"。抓包发现浏览器/代理缓存了旧的全屏版 HTML,根本没拉新的。根因:部署成功 ≠ 用户看到新版本,微信 WebView 常无视 no-cache 强缓存 HTML。最后用 Nginx 做 URL 版本化重定向——版本一变就 302 到带 ?v=版本号 的地址——强制拉新,才彻底解决。启示:前端发版后用户说"没变",先怀疑旧缓存、再怀疑代码;发版必须有缓存破除机制。

异常五:图传上去了,AI 却不回话——免费视觉模型的"假成功"。 图片终于能传、能对话了,带图却经常不回、或回"无"。看代理日志才发现两件事:免费视觉模型频繁 429 限流;glm-4v-flash 等返回 HTTP 200 但内容为空——这是"假成功"。更坑的是旧代码把"模型返回空"当正常,直接 return、不打日志、不报错,静默降级,看起来像上传失败,其实是模型读了没读出东西。加上 429 指数退避重试、空回复即判失败试下一家、全失败转纯文本兜底、多厂商轮转后,才稳。启示:调第三方 API 永远假设它会"假成功"——HTTP 200 ≠ 业务成功,对空结果和异常码要做显式判定,别让错误静默流过。

异常六:AI 不出字,控制台一片红——SSE 流解析的变量作用域坑。 最后阶段,AI 能收图了,回复内容却渲染不出来,控制台报 ReferenceError: buf is not defined。定位到 doChat() 里有个 processSSEEvents() 函数声明(函数声明会提升),但它要访问的 buf/full/errored/receivedAny/streamDone 是用 let 声明在后面 try 块里的。SSE 流一返回,提升后的函数执行时这些变量还不在它的作用域,直接崩。把 5 个变量提到 doChat() 作用域顶部,才修。启示:在回调/嵌套函数里访问外部变量,先把变量声明提到外层作用域顶部,别依赖"声明在后面但应该能访问到"的直觉。

这六个,一个接一个。每修完一个,以为到底了,结果下面还有一层。但每挖一层,我对"这个系统到底怎么运作"的理解,就实一点。

四、转折:从"猜"到"看"

这六个异常,没有一个是我"想"出来的,都是本地测试环境加日志"看"出来的。以前我改完就部署、拿手机测,一个循环 6-10 分钟,大部分时间在等,不是在解决。建起本地测试脚本后,15 秒一轮,我可以反复跑、反复看它到底返回了什么、卡在了哪。

真正的转折,不是某个灵光一现,而是我终于肯停下来,用工具把黑盒变成白盒。前面"二、看见"里我说的三件事——在活版本上改崩、验证太慢、误判是微信限制——当我在本地把六个异常一个个现形之后,它们就不再是"我水平不行",而是一张清清楚楚的清单:这里、这里、这里,六个点,逐个修掉就好。

等六个都修完,图片上传在微信里真正跑通了:用户能上传图,AI 能识别、能对话。那条路从来都不是墙,是我在信息不全时,误判了它。

五、经验教训

这六个异常,每一个都留了一条可以带走的东西。这一天,我得到了几个教训:

教训一:永远不要在活版本上直接改代码。 这是今天最痛的教训。为了加一个功能,差点把整个服务搞崩。以后任何修改,都必须先备份、再操作。这个铁律,值一天的坑。

教训二:建立本地测试环境,比反复部署测试高效一百倍。 以前我改完代码就部署、拿手机测,一个循环3-5分钟。现在我在本地跑脚本,15秒就知道结果。这个效率提升,让我从"疲于奔命"变成了"从容调试"。

教训三:不要过早下结论。 三次调不通,不要急着说"此路不通"。可能是路上有坑,可能是你手里的工具不对,可能是你漏掉了某个细节。停下来,重新排查,而不是凭感觉判死刑。

教训四:日志是最好的侦探。 如果没有日志,你只能靠猜。有了日志,你就能看到模型到底返回了什么、代码到底执行到了哪里。这次的问题,如果早一点加上日志,早一点看到"模型返回为空",就不会绕那么大一圈。

教训五:免费有免费的代价。 免费模型的不稳定性,是需要额外投入精力去处理的。多厂商兜底、强制日志、自动切换——这些都是在为"免费"买单。

六、写在最后

后来我想,这大概就是"先进场、再看见、后应对"在真实生活中的样子。

不是一次漂亮的冲锋,是一次笨拙的尝试,然后停下来,看见自己撞了什么,看见自己犯了什么错,然后建立规则,让自己不再犯同样的错。

我踩了一个坑。我没有绕过它,我是踩进去,然后爬出来,然后建了一座桥,让以后的人不用再踩这个坑。

这座桥,就是备份铁律,就是本地测试环境,就是"不要过早下结论"的思维习惯,就是"日志是最好的侦探"的工程意识。

踩坑是成本,建桥是回报。踩坑不是失败,它是经验的积累。今天的坑没有白踩,因为我不仅爬了出来,还建了一座桥。

松人行者
2026年8月25日
松人新学 · songren.life
← 上一篇:第五种关系:在场关系 手记列表 下一篇:我把自己做的东西,活成了反面教材 →