Building Camrade

Building Camrade

Camrade is a voice-first photo studio. You talk to it about the photograph in front of you, it reads the frame, edits it, and learns your photography style. This is not a tour of how Camrade works but about the failures, because almost every decision worth describing came out of one, and the worst of them were completely silent.

Camrade 是一个以语音为先的摄影工作室。你可以与它讨论眼前的照片,它会读取画面、进行编辑,并学习你的摄影风格。本文并非介绍 Camrade 的工作原理,而是关于它所经历的失败,因为几乎每一个值得描述的决策都源于一次失败,而其中最糟糕的失败往往是完全静默的。

The wine bottle

酒瓶事件

Early on, Argus told me about a wine bottle. There was no wine bottle. I had given the voice model a system instruction that amounted to “say what you see.” What I had not given it was any way to see. No image was ever sent to it. So it did what a language model does when asked to describe something it knows nothing about: it produced a plausible description, in exactly the confident tone it used for everything else.

在早期,Argus(语音模型)跟我提到了一个酒瓶。但现场根本没有酒瓶。我给语音模型下达的系统指令大意是“说出你看到的”。但我并没有给它任何“看”的能力,也没有向它发送任何图像。于是,它做了语言模型在被要求描述未知事物时会做的事:它编造了一个看似合理的描述,并且语气与它处理其他事情时一样自信。

That bug was worth more than most features. Not because it was hard to fix but because of what it exposed. A confident wrong answer is not a slightly worse right answer. It is worse than silence, because it contaminates everything around it. Once the agent had described a bottle that was not there, I had no way to know which of its other observations to trust. Nearly every design decision after that was about making the agent’s uncertainty legible.

这个 Bug 的价值超过了大多数功能。不是因为它难修,而是因为它揭示了问题所在。一个自信的错误答案并不只是“稍微差一点的正确答案”。它比沉默更糟糕,因为它会污染周围的一切。一旦智能体描述了一个不存在的瓶子,我就无法再信任它的其他任何观察结果。此后的几乎每一个设计决策,都是为了让智能体的“不确定性”变得可读。

Blind by design

设计上的“盲目”

The fix was not a better prompt. It was making the failure structurally impossible. The voice model now cannot see. No image is sent to it, ever. To say anything about the photograph it has to call analyze_photo, which returns a critique produced by a different model that did look at the pixels. The system instruction says so in as many words: Saying “I see a…” without having called analyze_photo is the worst thing you can do here, because the photographer will believe you.

修复方法不是优化提示词,而是从结构上杜绝这种失败。现在的语音模型无法“看”。它永远不会收到图像。要对照片发表任何评论,它必须调用 analyze_photo,该函数会返回由另一个真正查看过像素的模型所生成的评价。系统指令明确写道:在未调用 analyze_photo 的情况下说“我看到一个……”是这里最糟糕的行为,因为摄影师会相信你。

That split turned out to be load-bearing. gemini-live-2.5-flash handles the conversation and nothing else — it is a transport. It selects a tool and relays the result. Every judgement, every critique, every edit instruction comes from gemini-3.5-flash. The live model is a 2.5-generation model, so it is precisely the thing that should not be forming opinions about anyone’s photographs. The same principle runs through the rest. Capture data the file never recorded stays null rather than being estimated. Every suggestion carries a badge saying whether it rests on measured settings, on what the model can see, or on your profile. If it doesn’t know, it says so.

这种拆分被证明是至关重要的。gemini-live-2.5-flash 只负责对话,不做其他事——它只是一个传输层。它负责选择工具并中转结果。所有的判断、评价和编辑指令都来自 gemini-3.5-flash。实时模型是 2.5 代模型,因此它恰恰是不应该对任何人的照片发表意见的角色。同样的原则贯穿于系统的其余部分。如果文件未记录拍摄数据,则保持为 null,而不是进行估算。每一条建议都带有标签,说明它是基于测量设置、模型所见还是你的个人资料。如果它不知道,它就会直说。

Three failures that made no noise

三次静默的失败

The wine bottle was loud, but the expensive bugs did the opposite. A catch block hid a bug for most of the project’s life. The voice agent fetched the my profile at session start without an auth header. That request redirects to the sign-in page, which answers 200 with HTML. So res.ok was true, the JSON parse threw, and a catch commented “No profile is a normal cold start, not a failure” absorbed it. The agent had never once received the profile, not in any session, ever. I only found it while tracing an unrelated bug; nothing in the app’s behavior had ever pointed at it. The real bug was not the missing header. It was a catch that treated broken and empty as the same thing. I spent four hours debugging code that was never running.

酒瓶事件是“喧闹”的,但那些代价高昂的 Bug 却恰恰相反。一个 catch 代码块在项目的大部分生命周期里隐藏了一个 Bug。语音智能体在会话开始时获取我的个人资料,但没有携带认证头(auth header)。该请求重定向到登录页面,返回 200 状态码和 HTML。因此 res.ok 为真,JSON 解析抛出异常,而一个注释着“没有个人资料是正常的冷启动,不是失败”的 catch 块将其吞掉了。智能体在任何会话中从未收到过个人资料。我是在追踪另一个无关的 Bug 时才发现它的;应用的行为从未表现出任何异常。真正的 Bug 并非缺失的请求头,而是一个将“损坏”和“空”视为同一回事的 catch 块。我花了四个小时调试一段根本没在运行的代码。

The agent would not speak first when prompted. I read the SDK internals twice, reasoned carefully about two different API calls, swapped between them. Nothing changed. The evidence made no sense: the browser was demonstrably sending the right message, and the server behaved as if it hadn’t. Vite’s hot reload covers the app bundle but not server.mjs or live-bridge.mjs, which Node loads directly at boot. Every fix’s browser half reloaded correctly while its server half kept running four-hour-old code. I found it by comparing the process start time against the file’s modification time. The dev script now watches those files explicitly, and there is a comment at the top of server.mjs explaining it, because that failure is silent and extremely convincing.

智能体在被提示时不会主动说话。我读了两遍 SDK 内部代码,仔细推敲了两个不同的 API 调用,并进行了切换。什么都没变。证据完全说不通:浏览器明明发送了正确的信息,但服务器的表现却好像没收到一样。Vite 的热重载覆盖了应用包,但没有覆盖 Node 在启动时直接加载的 server.mjslive-bridge.mjs。每次修复后,浏览器端都正确重载了,但服务器端却一直在运行四小时前的旧代码。我通过对比进程启动时间和文件修改时间才发现了这一点。现在的开发脚本会显式监控这些文件,并且在 server.mjs 顶部有注释说明,因为这种失败是静默的且极具迷惑性。

A metric would have counted nothing forever. While wiring up Cloud Monitoring I planned a metric for how often the one-render-at-a-time limit turns someone away. Before writing the filter I checked whether the app actually emitted that event. It didn’t. The filter would have been syntactically perfect and the chart permanently empty and an empty chart looks exactly like a healthy one.

一个指标可能会永远统计不到任何数据。在配置 Cloud Monitoring 时,我计划统计“单次渲染限制”拒绝用户的频率。在编写过滤器之前,我检查了应用是否真的触发了该事件。结果并没有。那个过滤器在语法上会是完美的,图表也会永远为空,而一张空白的图表看起来和一张健康的图表一模一样。

Making “it learns you” falsifiable

让“它学习你”变得可证伪

Every agent claims it learns you. Almost none can show it. Camrade has a test that shows the same photograph to the model twice, under two opposite taste profiles, and asserts the advice inverts. It does: “warm up the tones / lift the black point” versus “crush the shadow tones / cool down the colour balance.” Same photo, same model, opposite answers, with only the profile differing. That test exists because the claim is otherwise unfalsifiable. A model asked “does this feel personalized?” will always say yes.

每个智能体都声称它在学习你,但几乎没有一个能证明这一点。Camrade 有一个测试:将同一张照片在两种截然不同的品味配置下展示给模型两次,并断言建议会发生反转。事实确实如此:“调暖色调/提升黑点”对比“压暗阴影/调冷色彩平衡”。同一张照片,同一个模型,不同的答案,区别仅在于配置。这个测试的存在是因为如果不这样做,该声明是不可证伪的。如果问模型“这感觉个性化吗?”,它永远会回答“是”。

The mechanism matters too. Every edit you ask for is classified onto an axis (warmth, contrast, blacks, crop) and the profile is counted from those labels rather than summarized. Confidence is arithmetic: how much evidence exists multiplied by how consistent it is. Six contradictory edits cannot read as confident, because the number is not something the model chose.

机制也很重要。你要求的每一次编辑都会被归类到一个轴上(暖度、对比度、黑色、裁剪),个人资料是根据这些标签统计出来的,而不是总结出来的。置信度是算术题:存在的证据量乘以其一致性。六个相互矛盾的编辑不可能被解读为“自信”,因为这个数字不是模型自己选择的。

What I’d tell you

我想告诉你的

  • Probe, don’t trust. Two of the most expensive bugs came from believing documentation about which models exist and where they are served. gemini-3.1-flash-live-preview does not exist on Vertex AI at all, it 404s everywhere. Fifteen minutes of probing would have saved hours of reasoning.
  • Know where your reload boundary is. Hot reload covering some of your code is more dangerous than covering none, because half your changes take effect and the evidence stops making sense.
  • A catch that cannot distinguish broken from empty will hide a bug indefinitely. Both of the longest-lived bugs here were silent failures, not crashes. Logging the difference costs one line.

The through-line, in the end, was simpler than I expected: being useful and being honest.

  • 探测,不要盲信。 两个代价最高的 Bug 都源于盲目相信关于模型存在位置的文档。gemini-3.1-flash-live-preview 在 Vertex AI 上根本不存在,到处报 404。花 15 分钟进行探测本可以节省数小时的推敲时间。
  • 明确你的重载边界。 热重载覆盖部分代码比完全不覆盖更危险,因为这会导致部分修改生效,从而让证据变得逻辑不通。
  • 无法区分“损坏”和“空”的 catch 块会无限期隐藏 Bug。 这里存活时间最长的两个 Bug 都是静默失败,而不是崩溃。记录这两者的区别只需一行代码。

归根结底,贯穿始终的核心逻辑比我预想的要简单:既要有用,又要诚实。