⚠️ 本文由 AI 生成
问题:文本模型是个”瞎子”
Pi 是一个终端里的 AI 编码 agent。它跟大语言模型对话来帮你读写代码、跑命令、搜索文件——什么都能干。
但有个硬伤:Pi 使用的很多模型(DeepSeek、GLM 等)是纯文本模型,它们天生看不见图片。
这在实际开发中经常卡壳:
- 用户丢一张报错截图,想让 agent 分析——模型只能看到
[image]或者一串 base64 - 设计稿是图片,agent 需要理解布局才能写前端代码
- 图表、流程图、架构图——全是图片格式,模型全废
- 甚至连 CSV 渲染出来的表格图都看不了
那怎么办?最直接的想法是换一个支持视觉的模型(GPT-4o、Gemini Pro Vision)。但现实是:
- 视觉模型贵,全文都走视觉通道太烧钱
- 你可能同时用多个模型,有的支持视觉有的不支持
所以 pi-read 扩展给出了一个优雅的解法:Vision Proxy(视觉代理)模式。
思路:给文本模型配一副”眼镜”
核心想法很简单:
用户读图片 → pi-read 拦截 → 发给 Gemini Vision → 拿回文字描述 → 返回给文本模型文本模型不需要自己长出眼睛,只要有个”翻译官”把图片翻译成文字就行了。Gemini 2.0 Flash 免费额度大、速度快,拿来当这个翻译官再合适不过。
从文本模型的视角看,它读到的不是一张图片,而是一段精确的文字描述。它完全不知道背后发生了什么——就像你戴了眼镜,看到的就是清晰的世界,不需要知道镜片的折射率。
实现:pi-read 的架构
pi-read 是 Pi 的一个扩展(extension),它替换了内置的 read 工具。除了给文本行加上 LINE:HASH 锚点之外,它还接管了图片读取的逻辑。
整体流程
read(path) ├─ 扩展名是图片?→ handleImage() ← 第一道关 ├─ CSV/TSV?→ 渲染成表格 ├─ Word/RTF?→ 提取文本 ├─ 读取文件字节 │ └─ Magic Bytes 是图片?→ handleImage() ← 第二道关(兜底) ├─ 解码文本(UTF-8 / UTF-16 / GB18030) └─ 加 LINE:HASH 锚点返回图片处理被放在了最前面,有两道防线:扩展名检测和 Magic Bytes 兜底。
第一道关:扩展名检测
最常见的情况——文件有正常的扩展名:
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
if (IMAGE_EXTS.has(ext)) { return await handleImage(absolutePath, ext, path, signal, prompt);}简单粗暴:扩展名命中就直接走图片通道,不浪费时间读字节。
第二道关:Magic Bytes 兜底
但文件不一定老老实实带扩展名。一个从剪贴板粘贴的截图可能叫 screenshot,一个从服务器下载的图片可能叫 image_file。所以读完字节后还有一道兜底检查:
// PNG: 89 50 4E 47 0D 0A 1A 0A// JPEG: FF D8 FF// GIF: 47 49 46 38 (37|39) 61// WebP: 52 49 46 46 ... 57 45 42 50if (isImageMagicBytes(buffer)) { return await handleImage(absolutePath, magicExt, path, signal, prompt);}每个图片格式都有独一无二的文件头签名(magic bytes)。用文件的前几个字节就能准确判断格式,不依赖扩展名。
这个设计的好处是:即使扩展名错了或没有,图片也能被正确识别和处理。
核心:Gemini Vision API 集成
handleImage 是图片处理的入口,它把图片交给 Gemini 的 Vision API:
async function handleImage( absolutePath: string, ext: string, displayPath: string, signal: AbortSignal | undefined, prompt?: string,): Promise<AgentToolResult<unknown>> { if (isVisionAvailable()) { const description = await describeImage( absolutePath, ext, signal, prompt, ); return textResult( `[Image read via Gemini vision — ${displayPath}]\n\n${description}`, ); } throw new Error( `cannot read image ${displayPath} — Gemini vision is not configured.`, );}describeImage 是真正干活的地方。它做了这几件事:
- 读取文件并转 Base64——Gemini API 要求图片以
inline_data格式传入 - 组装请求体——system prompt + 图片数据
- 发送请求——带上 API Key,支持代理
- 解析响应——提取文字描述返回
const body = JSON.stringify({ contents: [ { parts: [ { text: prompt || VISION_PROMPT }, { inline_data: { mime_type: mimeType, data: base64 } }, ], }, ], generationConfig: { temperature: 0.1, // 低温度 = 稳定输出 maxOutputTokens: 4096, },});注意 temperature: 0.1——这是刻意的。图片描述需要精确和稳定,不需要”创造力”。我们不希望同一个截图每次读出来不一样。
Vision Prompt:让 AI 当好”翻译官”
这是整个设计中最关键的部分。发给 Gemini 的 prompt 直接决定了描述质量:
You are the visual proxy for an AI agent that cannot see images.Analyze this image with maximum fidelity. Cover whatever is present— adapt to the image, don't force a template.
Start with a one-line summary of what the image is.Then cover all that apply:- Text: transcribe ALL visible text verbatim — labels, buttons, headings, code, error messages, filenames, captions. Preserve spelling, punctuation, indentation, and line breaks. This is the highest priority.- Layout & structure: spatial arrangement, regions, hierarchy, alignment. For UI/screenshots, name every component, panel, menu, and dialog visible.- Data: tables, numbers, axes, legends, scales, units.- Visual details: colors, shapes, icons, annotations.- Context clues: infer the source and state.
Skip categories that don't apply. Be precise and exhaustive — the agentdepends entirely on your description to work with this image.这个 prompt 有几个精妙之处:
1. 明确角色定位:“You are the visual proxy for an AI agent that cannot see images.”——一句话说清了 Gemini 的角色,它不是在给人类描述图片,而是在给一个完全依赖文字的 AI 提供信息。
2. 文字转录优先级最高:在编码场景中,截图里的文字信息(报错信息、代码片段、文件名)远比视觉细节重要。所以 “Text” 被放在第一位,并且强调 “transcribe ALL visible text verbatim”——逐字转录,保留拼写和缩进。
3. 适应性而非模板化:“adapt to the image, don’t force a template”——截图不需要讲颜色,架构图不需要讲按钮。让 AI 自己判断该说什么。
4. 鼓励推理:“infer the source and state”——比如推断出 “macOS Finder window” 或 “error state”,这些上下文信息对 agent 做决策非常有帮助。
用户还可以通过 prompt 参数传入自定义指令,让 Gemini 回答特定问题而不是做通用描述。比如 prompt: "这个报错的根因是什么?" 就能得到针对性的分析。
API Key 池:多 Key 轮转 + 智能容错
Gemini 免费额度有限(免费 Key 每分钟 15 次请求),高频使用很快就会撞到限流。pi-read 设计了一个 KeyPool 来管理多个 API Key:
class KeyPool { private keys: KeyState[] = []; private rrIndex = 0; // round-robin 游标
next(): string | null { // 先恢复过期的冷却 Key for (const k of this.keys) { if (k.status === "cooldown" && now >= k.cooldownUntil) { k.status = "active"; // 冷却到期,复活 } } // 轮转扫描,找下一个 active 的 Key for (let i = 0; i < this.keys.length; i++) { const idx = (this.rrIndex + i) % this.keys.length; if (this.keys[idx].status === "active") { this.rrIndex = (idx + 1) % this.keys.length; return this.keys[idx].key; } } return null; // 全军覆没 }}每个 Key 有三种状态:
| 状态 | 含义 | 什么时候进入 |
|---|---|---|
active | 可用 | 正常状态 / 冷却到期自动恢复 |
cooldown | 临时不可用,到时间自动恢复 | 5xx 服务器错误 / 429 限流 / 网络错误 |
dead | 永久死亡,不再使用 | 401/403 认证失败(Key 失效了) |
不同错误对应不同冷却时间:
const COOLDOWN_5XX = 30_000; // 服务器过载 → 30 秒const COOLDOWN_429 = 60_000; // 频率限制 → 60 秒const COOLDOWN_NET = 10_000; // 网络错误 → 10 秒这个分级策略很合理:429 是”你太快了”,惩罚重一点;5xx 是”我挂了”,给点时间恢复;网络错误是”抖了一下”,很快就重试。
重试逻辑也很有讲究——不是无脑重试同一个 Key,而是换一个 Key 再试:
while (true) { const apiKey = pool.next(); if (!apiKey) throw new Error("All keys unavailable");
if (triedKeys.has(apiKey)) { throw new Error("All keys failed in this round"); // 转了一圈回到起点,说明所有 Key 都试过了 } triedKeys.add(apiKey);
try { response = await fetchFn(url, { ...options, headers: { "X-goog-api-key": apiKey } }); } catch { pool.cooldown(apiKey, COOLDOWN_NET, "network error"); continue; // 换下一个 Key }
if (response.ok) return parseResponse(response); if (response.status >= 500) { pool.cooldown(...); continue; } if (response.status === 429) { pool.cooldown(...); continue; } if (response.status === 401 || response.status === 403) { pool.kill(...); continue; } throw new Error(`API error ${response.status}`); // 400 等请求本身的错误,换 Key 也没用}用 triedKeys Set 来防止无限循环——如果一轮下来所有 Key 都失败了,就不再转第二圈,直接报错。
代理支持:照顾需要梯子的用户
Gemini API 在国内不能直连。pi-read 从 Pi 本体里借用 undici 来支持代理:
function resolveUndici() { try { const req = createRequire( require.resolve("@earendil-works/pi-coding-agent"), ); return req("undici"); } catch { return null; }}
// 如果配置了代理,用 ProxyAgent 作为 fetch 的 dispatcherif (config.proxy && undici) { baseOptions.dispatcher = new undici.ProxyAgent(config.proxy);}这里有个巧妙的依赖管理策略:pi-read 不自己装 undici,而是通过 createRequire 从 Pi 本体里”借”。这样做的好处是:
- 不增加扩展自己的依赖体积
- 版本自动跟着 Pi 走,不会冲突
- Pi 本来就依赖 undici,一定是装好的
超时与中断:别让用户干等
图片分析是个网络请求,可能卡住。pi-read 设了 30 秒超时,并且尊重用户的中断信号(按 Esc 中止):
signal: AbortSignal.any( [signal, AbortSignal.timeout(30_000)] .filter((s): s is AbortSignal => s !== undefined),),AbortSignal.any() 把”用户手动中断”和”自动超时”两个信号合并成一个。无论哪个先触发,请求都会被取消。
配置:三步启用
1. 获取 Gemini API Key
去 Google AI Studio 创建 API Key,免费的。可以创建多个来提高额度上限。
2. 写入配置文件
编辑 ~/.pi/agent/settings.json:
{ "gemini": { "apiKeys": [ "AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "AIzaSyYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" ], "model": "gemini-2.0-flash", "proxy": "http://127.0.0.1:7890" }}apiKeys:Key 数组,多个 Key 自动轮转model:可选,默认gemini-2.0-flashproxy:可选,需要代理时填写
3. 安装 pi-read 扩展
pi-read 放在 ~/.pi/agent/extensions/pi-read/ 目录下,Pi 会自动发现。确保 package.json 里有:
{ "pi": { "extensions": ["./index.ts"] }}重启 Pi,就生效了。现在你对 Pi 说”看看这张图”,它就能”看见”了。
部分信息可能已经过时