2473 字
12 分钟
如何在 Pi 中实现文本模型的图片读取
2026-07-27
统计加载中...

⚠️ 本文由 AI 生成

问题:文本模型是个”瞎子”#

Pi 是一个终端里的 AI 编码 agent。它跟大语言模型对话来帮你读写代码、跑命令、搜索文件——什么都能干。

但有个硬伤:Pi 使用的很多模型(DeepSeek、GLM 等)是纯文本模型,它们天生看不见图片。

这在实际开发中经常卡壳:

  • 用户丢一张报错截图,想让 agent 分析——模型只能看到 [image] 或者一串 base64
  • 设计稿是图片,agent 需要理解布局才能写前端代码
  • 图表、流程图、架构图——全是图片格式,模型全废
  • 甚至连 CSV 渲染出来的表格图都看不了

那怎么办?最直接的想法是换一个支持视觉的模型(GPT-4o、Gemini Pro Vision)。但现实是:

  1. 视觉模型贵,全文都走视觉通道太烧钱
  2. 你可能同时用多个模型,有的支持视觉有的不支持

所以 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 50
if (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 是真正干活的地方。它做了这几件事:

  1. 读取文件并转 Base64——Gemini API 要求图片以 inline_data 格式传入
  2. 组装请求体——system prompt + 图片数据
  3. 发送请求——带上 API Key,支持代理
  4. 解析响应——提取文字描述返回
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 agent
depends 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 的 dispatcher
if (config.proxy && undici) {
baseOptions.dispatcher = new undici.ProxyAgent(config.proxy);
}

这里有个巧妙的依赖管理策略:pi-read 不自己装 undici,而是通过 createRequire 从 Pi 本体里”借”。这样做的好处是:

  1. 不增加扩展自己的依赖体积
  2. 版本自动跟着 Pi 走,不会冲突
  3. 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-flash
  • proxy:可选,需要代理时填写

3. 安装 pi-read 扩展#

pi-read 放在 ~/.pi/agent/extensions/pi-read/ 目录下,Pi 会自动发现。确保 package.json 里有:

{
"pi": {
"extensions": ["./index.ts"]
}
}

重启 Pi,就生效了。现在你对 Pi 说”看看这张图”,它就能”看见”了。

如何在 Pi 中实现文本模型的图片读取
https://www.trtyr.top/posts/how-to-implement-image-reading-for-text-models-in-pi/
作者
特让他也让
发布于
2026-07-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时