news 2026/9/18 11:07:53

Google AI Studio批量导出:从AI导出鸭到自建脚本

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Google AI Studio批量导出:从AI导出鸭到自建脚本

1. 先把问题说清楚:Google AI Studio 到底能不能“批量导出”

上周有个做内容的朋友甩给我一句话:我在 Google AI Studio 里攒了三百多条提示词和对话记录,一条一条复制粘贴,手都快抽筋了,有没有办法在电脑上一次全导出来?他试过好几个号称能“批量导出”的小工具,被问得最多的就是“AI导出鸭”。这也是我写这篇东西的直接原因——不是给某个工具做评测,而是把「Google AI Studio 批量导出」这件事从头到尾拆一遍,让你清楚哪些路能走、哪些路看着能走其实走不通、以及自己动手怎么搭一套能长期用的方案。

先把结论摆在前面,省得你读到最后才发现方向不对:Google AI Studio 本身确实没有“一键导出全部会话”的按钮,但电脑端做批量导出这件事是成立的,而且实现路径不止一条。区别只在于你愿意付出多少折腾成本,以及你要导出的到底是什么——是渲染在页面上的那层文字,是浏览器里躺着的原始结构化数据,还是你用接口重新跑一遍拿到的归档。这三样东西看上去差不多,实际上精度、稳定性、合规性完全不是一个量级。

1.1 官方能力的边界到底划在哪里

打开 AI Studio 随便进一个会话,你能看到的“导出”动作其实就三类。第一类是 Get code,把当前这段配置和输入转成 Python、JavaScript 或 cURL 的调用代码,本质是帮你把参数搬到本地脚本里,导的是代码不是内容。第二类是下载生成出来的文件,比如模型产出的图片、音频,点一下就能落盘,但这些是单文件下载,跟“批量”没关系。第三类是复制,把某一条回复的文本拷到剪贴板,一次一条。

再往上看,左侧栏里能保存提示词(Saved prompts)、能整理会话历史,但这些都停留在“在页面里管理”的层面,没有“全选然后导出成压缩包”这种入口。所以很多人第一次用完之后会有一个很自然的错觉:功能这么全,导出应该也有吧?翻遍设置找不着,于是转头去搜第三方工具。这不是你笨,是产品定位决定的——它被设计成一个试验台,不是资产管理工具。

这里有个容易被忽略的细节:AI Studio 里的会话不是线性列表,而是树。你在某一条回复下面点重试、点编辑再生成,都会分叉出新的分支,页面上只显示你当前选中的那条路径。如果你用页面抓取的方式导出,抓到的永远是“当前可见路径”,那些被你重试掉的旧分支会直接消失。这一点在做对比实验的时候特别致命,很多人导完才发现少了一半数据,还以为是工具坏了。

1.2 “批量”这两个字才是真正的难点

单条导出,说实话怎么都能凑合。真正难的是批量,而批量的难,集中在四个地方。

页面上的消息是懒加载的。你往上滚动,旧消息才会被渲染出来;不滚动,那些节点根本不在 DOM 里。这意味着任何依赖 DOM 抓取的方案,都必须先把整个会话从头滚到尾,而且滚动速度太快会漏,太慢又等到天荒地老。三百条会话按每条滚十秒算,就是五十分钟纯等待。

会话数据存在本地数据库里,不在页面的可见结构里。你在开发者工具的 Application 面板往下翻,能在 IndexedDB 或本地存储里看到一些键值对,那里面的结构和页面渲染出来的结构完全不一样。这才是“真身”,但它是内部格式,没有对外承诺,随时可能变。

多模态内容是临时地址。模型生成的图片、你上传的 PDF,在页面上是一个带签名的链接,这个链接是有有效期的。你今天抓下来的 HTML 存着不动,过两天打开,图全裂了。想把多模态内容真正归档,必须在当时就把二进制拉下来存成本地文件。

批量意味着要遍历、要等待、要重试。一次成功不算成功,跑五百次还能不能稳住才是问题。这里面牵扯限流、断点续传、去重、异常恢复一大堆工程细节,恰恰是那些“一键导出”工具最容易糊弄过去的地方。

1.3 到底哪些人真的需要它

我把问过我这个问题的人归了归类,大致四种,需求差别挺大。

做提示词工程的,把 AI Studio 当成提示词 IDE 在用,手里攒了几百个版本的提示词,需要定期归档、做 diff、沉淀到团队的知识库里。他们要的是结构化数据和版本可追溯。

做内容生产的,用 AI Studio 批量跑文案、跑脚本,跑完要按项目分类存档,可能还要二次编辑。他们要的是干净的可读文本,最好是 Markdown。

做研究或者写论文的,同一个问题要跑好几个模型、好几组参数做对照,需要把原始输入输出连同时间戳一起留档,将来要能复现。他们要的是完整性和不可篡改性。

还有一类是团队协作场景,个人在 AI Studio 里试出来的东西,最终要进内部系统。他们要的是能自动化的管道,而不是手工点鼠标。

搞清楚自己属于哪一类,后面的方案选择就顺理成章了。

2. “AI导出鸭”这类工具,底层到底动了哪几层

名字起得挺萌,但这类批量导出工具在技术上并没有魔法,能下手的地方就那么几层。我把市面上同类工具的公开功能形态反推了一遍,基本上都是在这四层里选一层或者组合几层来做。理解这四层,你自己也能判断一个工具靠不靠谱。

2.1 第一层:DOM 层抓取,最直白也最脆

这是最朴素的思路。写一个浏览器扩展或者油猴脚本,在页面注入一段代码,等页面加载完之后用选择器把消息气泡一个个抓出来,根据气泡的样式或者头像判断这条是用户发的还是模型回的,然后按顺序拼成一段 Markdown,最后弹个下载框。

优点是简单,不需要理解任何接口协议,页面长什么样就抓什么样。缺点也很直接:它抓的是“渲染结果”,不是“数据本身”。只要前端改一次类名,脚本当场报废。而且代码块里的复制按钮文字、引用块的行号、折叠区域的“展开”按钮,这些都会被一并抓进去,清洗起来很烦。

更麻烦的是流式输出。模型的回复是一个字一个字蹦出来的,如果你在它还没蹦完的时候就把内容读走了,拿到的是半截。正经的做法是监听生成状态,等按钮从“停止”变回“发送”再去读,但这个状态判断本身也不稳定。

提示:任何纯 DOM 抓取的导出工具,都要做好“用一次修一次”的心理准备。它适合应急,不适合当长期基础设施。

2.2 第二层:网络层拦截,性价比最高的一层

页面要显示内容,必然得先从服务端把数据拿回来。抓渲染结果不如直接抓回来的那份原始数据。这就是网络层拦截的思路。

具体做法是在页面上下文里改写window.fetchXMLHttpRequest.prototype.open/send,把原始方法存一份,然后包一层。请求发出去、响应回来之后,不直接返回给调用方,而是先clone()一份,把内容读出来存进自己的缓冲区,再把原始响应原封不动返回。对页面来说完全无感,对你来说所有数据都到手了。

这里有个坑我踩过:浏览器扩展的内容脚本默认跑在“隔离世界”里,拿到的window和页面自身的window不是一个对象。你在隔离世界里改写fetch,页面的代码根本感知不到。解决办法有两个,一是在扩展清单里把注入世界声明成MAIN,二是用document.createElement('script')往页面里插一段代码,让它在页面上下文里执行。

// 在页面上下文中执行的最小拦截器 (function () { if (window.__netHookInstalled) return; window.__netHookInstalled = true; window.__captured = []; const origFetch = window.fetch; window.fetch = async function (...args) { const res = await origFetch.apply(this, args); try { const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; // 只关心生成类接口,其余流量直接放过,避免缓冲区爆炸 if (/generateContent|streamGenerateContent/i.test(url)) { const clone = res.clone(); clone.text().then((t) => { window.__captured.push({ url, raw: t, ts: Date.now() }); }).catch(() => {}); } } catch (e) { /* 拦截失败绝不能影响页面本身 */ } return res; }; })();

网络层的好处是拿到的 JSON 是结构化的,candidates[0].content.parts里就是纯文本,想怎么落地就怎么落地。多模态的图片会以 base64 或者带签名的地址形式出现,前者可以直接解码存文件,后者需要当场下载。

2.3 第三层:直读浏览器存储

会话数据既然要在本地留存,就一定会落进某种存储里。你可以在开发者工具里翻 Application 面板,大概率能在 IndexedDB 里看到相关的库和表。直接读存储的好处是不依赖网络、不依赖页面渲染速度,一次遍历就能把全部会话捞出来。

但这一层的门槛也最高。第一,库名和表名是内部实现,没有对外文档,版本更新就可能变。第二,表结构可能是嵌套对象加压缩字段,你得先反序列化再理解字段含义。第三,从内容脚本访问页面所属源的 IndexedDB,在某些场景下会受权限限制。

我一般的用法是把这一层当“兜底”而不是“主力”:网络层拿不到的,再回头去存储里翻。

2.4 第四层:走官方接口重放,最干净也最可控

如果你要的不是“把历史对话原样搬走”,而是“把我在 AI Studio 里验证过的那批提示词重新跑一份归档”,那最干净的做法其实是走官方接口。

流程很简单:在 AI Studio 里申请一个 API Key,把提示词整理成一个本地文件(比如 JSON 或者 CSV,一行一条),然后写个脚本批量调用、批量落盘。整个过程数据流向清晰,不依赖页面结构,不会因为前端改版而失效。

代价有两个。一是你要重新消耗调用额度,因为你是在重新生成,而不是搬运。二是你拿到的是“模型现在的输出”,不是你当时看到的那个输出。做版本对比的时候要留意这一点。

2.5 四种路线横向比一比

路线数据精度抗改版能力实现难度多模态支持适合场景
DOM 抓取低,带渲染噪音很弱,改版即失效需额外处理应急、单次少量
网络拦截高,原始 JSON中,接口路径会变好,可拿到 base64归档历史会话
直读存储高,但格式不公开弱,schema 会变取决于存储格式网络层失效时兜底
接口重放高,但非历史原文强,接口相对稳定好,官方支持提示词批跑、长线自动化

看完这张表你会发现,“AI导出鸭”这类工具真正的技术含量,不在于它用了哪一层,而在于它在异常处理、格式清洗、批量稳定性上做了多少功课。而这些东西,恰恰是你自己搭一套的时候最容易忽略、也最值得认真做的部分。

3. 手把手:不装任何插件,自己把批量导出跑通

下面这套我按数据量从小到大分三级,你可以直接对号入座。三级之间不是互斥的,实际用的时候经常是小的用来救急、大的用来长期跑。

3.1 小批量:开发者工具手工抓一份,五分钟搞定

如果你只是想把某个会话存下来,条数不超过二十,别折腾脚本了,开发者工具足够。

按 F12 打开 Network 面板,勾选 Fetch/XHR 过滤,然后在页面上触发一次生成。你会看到一条返回体比较大的请求,点开看 Response,那一坨 JSON 就是你要的东西。右键选择保存响应,落成一个文件。

如果你要的是某个时间段的全部原始流量,可以用面板上的导出按钮把当前记录导成 HAR 文件。HAR 本质就是一个 JSON,里面按条目记录了每条请求的 URL、请求头、响应体。写个几十行脚本就能把它拆成一个个干净的 Markdown。

import json, re, pathlib har = json.loads(pathlib.Path("aistudio.har").read_text(encoding="utf-8")) out_dir = pathlib.Path("export"); out_dir.mkdir(exist_ok=True) def extract_text(payload: str) -> str: """从模型响应里把文本片段拼起来,流式返回会是多段 JSON 拼接""" texts = [] # 普通返回是一个完整 JSON;流式返回是若干行 JSON 或 JSON 数组 for chunk in re.split(r"\r?\n", payload): chunk = chunk.strip().rstrip(",") if not chunk or chunk in "[]": continue try: obj = json.loads(chunk) except json.JSONDecodeError: continue candidates = obj.get("candidates") or [] for cand in candidates: for part in (cand.get("content") or {}).get("parts") or []: if isinstance(part.get("text"), str): texts.append(part["text"]) return "\n".join(texts) idx = 0 for entry in har.get("log", {}).get("entries", []): url = entry["request"]["url"] if not re.search(r"generateContent", url): continue body = (entry.get("response", {}).get("content", {}) or {}).get("text", "") content = extract_text(body) if not content.strip(): continue idx += 1 (out_dir / f"{idx:04d}.md").write_text(content, encoding="utf-8") print("导出完成:", idx, "条")

这段代码里有两个地方值得说一下。一是流式接口的返回格式和普通接口不一样,可能是若干行 JSON 拼在一起,也可能是 JSON 数组,所以做了容错分割。二是判断条件只认路径里带generateContent的请求,不然会把页面加载的各种静态资源也一起处理,白白浪费时间。

注意:HAR 文件里会包含请求头,如果你的请求头里带了任何凭证类信息,分享这个文件之前一定要先清洗掉。

3.2 中批量:一个油猴脚本,自动累积一键导出

手工抓的痛点在于你要一次次重复操作。写个脚本让它自己记录,你只管点。

// ==UserScript== // @name 会话录制导出 // @namespace local.export // @version 1.0 // @description 拦截生成接口响应,累积后一键导出 JSON / Markdown // @match https://aistudio.google.com/* // @run-at document-start // @grant none // ==/UserScript== (function () { const BUF = "__captured_turns__"; if (window[BUF]) return; const captured = []; window[BUF] = captured; const hit = /generateContent|streamGenerateContent/i; function parse(payload) { const texts = []; payload.split(/\r?\n/).forEach((line) => { line = line.trim().replace(/,$/, ""); if (!line || line === "[" || line === "]") return; try { const obj = JSON.parse(line); (obj.candidates || []).forEach((c) => { ((c.content || {}).parts || []).forEach((p) => { if (typeof p.text === "string") texts.push(p.text); }); }); } catch (e) {} }); return texts.join("\n"); } const origFetch = window.fetch; window.fetch = async function (...args) { const res = await origFetch.apply(this, args); try { const url = typeof args[0] === "string" ? args[0] : (args[0] && args[0].url) || ""; if (hit.test(url)) { res.clone().text().then((t) => { const text = parse(t); if (text.trim()) captured.push({ ts: Date.now(), url, text }); }).catch(() => {}); } } catch (e) {} return res; }; // XHR 老接口也一并兜住 const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__url = url; return origOpen.call(this, method, url, ...rest); }; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (...a) { this.addEventListener("load", () => { try { if (hit.test(this.__url || "")) { const text = parse(this.responseText || ""); if (text.trim()) captured.push({ ts: Date.now(), url: this.__url, text }); } } catch (e) {} }); return origSend.apply(this, a); }; function download(name, content, type) { const blob = new Blob([content], { type }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); URL.revokeObjectURL(a.href); } function panel() { const box = document.createElement("div"); box.style.cssText = "position:fixed;right:16px;bottom:16px;z-index:99999;background:#111;color:#eee;" + "padding:10px 12px;border-radius:8px;font:12px/1.6 sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.4)"; box.innerHTML = '<span id="cap-count">0</span> 条'; const btnJson = document.createElement("button"); btnJson.textContent = "导出 JSON"; btnJson.style.cssText = "margin-left:8px;cursor:pointer"; btnJson.onclick = () => download(`capture_${Date.now()}.json`, JSON.stringify(captured, null, 2), "application/json"); const btnMd = document.createElement("button"); btnMd.textContent = "导出 Markdown"; btnMd.style.cssText = "margin-left:6px;cursor:pointer"; btnMd.onclick = () => download( `capture_${Date.now()}.md`, captured.map((c, i) => `## ${i + 1}\n\n${c.text}\n`).join("\n---\n\n"), "text/markdown" ); const btnClear = document.createElement("button"); btnClear.textContent = "清空"; btnClear.style.cssText = "margin-left:6px;cursor:pointer"; btnClear.onclick = () => { captured.length = 0; refresh(); }; box.append(btnJson, btnMd, btnClear); document.body.appendChild(box); function refresh() { box.querySelector("#cap-count").textContent = String(captured.length); } setInterval(refresh, 800); } window.addEventListener("load", () => setTimeout(panel, 1500)); })();

装好之后右下角会多一个小面板,显示当前已经抓到多少条。你把会话挨个点一遍,模型每回一次,脚本就默默记一条,最后点一下导出。实测下来,这种方式的稳定性远好于 DOM 抓取,因为它拿的是接口原始返回。

有几个细节值得留意。脚本加了@run-at document-start,必须比页面的业务代码更早执行,否则等你去改fetch的时候,人家早就保存好原始引用了。缓冲区只存在内存里,页面一刷新就清空,所以导出要勤快一点。还有就是别去改响应本身,只读 clone,任何对原始响应的干预都可能导致页面行为异常。

3.3 大批量:走官方接口,写个能断点续传的批量脚本

真正要跑几百上千条的时候,前面两种都不合适——它们都依赖你手动操作页面。这时候就得走接口。

先把提示词整理成结构化文件。这一步非常重要,我建议把它当成长期维护的单一事实来源,别再散落在各个会话里。

[ {"id": "p001", "tag": "产品文案", "prompt": "为下面这款产品写三条卖点……"}, {"id": "p002", "tag": "技术摘要", "prompt": "把下面这段内容压缩成 200 字……"} ]

然后是批量调用脚本:

import json, time, random, hashlib, pathlib from concurrent.futures import ThreadPoolExecutor, as_completed import requests API_KEY = "你的密钥" MODEL = "gemini-2.0-flash" ENDPOINT = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent" ROOT = pathlib.Path("batch_out") RAW_DIR = ROOT / "raw"; RAW_DIR.mkdir(parents=True, exist_ok=True) MD_DIR = ROOT / "md"; MD_DIR.mkdir(parents=True, exist_ok=True) STATE = ROOT / "_state.json" MAX_RETRY = 5 BASE_SLEEP = 1.5 MAX_WORKERS = 4 def load_state(): if STATE.exists(): return set(json.loads(STATE.read_text(encoding="utf-8"))) return set() def save_state(done): STATE.write_text(json.dumps(sorted(done), ensure_ascii=False), encoding="utf-8") def call_api(prompt: str): body = { "contents": [{"role": "user", "parts": [{"text": prompt}]}], "generationConfig": {"temperature": 0.7, "maxOutputTokens": 2048}, } last_err = None for attempt in range(MAX_RETRY): try: r = requests.post( ENDPOINT, headers={"Content-Type": "application/json", "x-goog-api-key": API_KEY}, json=body, timeout=90, ) if r.status_code in (429, 500, 502, 503, 504): # 指数退避 + 抖动,避免所有任务同时重试形成尖峰 sleep = BASE_SLEEP * (2 ** attempt) + random.uniform(0, 0.8) time.sleep(sleep) last_err = f"HTTP {r.status_code}" continue r.raise_for_status() return r.json() except requests.RequestException as e: last_err = str(e) time.sleep(BASE_SLEEP * (2 ** attempt) + random.uniform(0, 0.8)) raise RuntimeError(f"重试 {MAX_RETRY} 次仍失败:{last_err}") def extract_text(data: dict) -> str: parts = [] for cand in data.get("candidates", []): for p in (cand.get("content") or {}).get("parts", []): if isinstance(p.get("text"), str): parts.append(p["text"]) return "\n".join(parts) def key_of(item: dict) -> str: raw = f"{item['id']}|{item['prompt']}" return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] def worker(item: dict): k = key_of(item) data = call_api(item["prompt"]) (RAW_DIR / f"{item['id']}_{k}.json").write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) text = extract_text(data) (MD_DIR / f"{item['id']}_{k}.md").write_text( f"# {item['id']}\n\n标签:{item.get('tag','')}\n\n## 提示词\n\n{item['prompt']}\n\n## 输出\n\n{text}\n", encoding="utf-8", ) return k def main(): items = json.loads(pathlib.Path("prompts.json").read_text(encoding="utf-8")) done = load_state() todo = [it for it in items if key_of(it) not in done] print(f"总 {len(items)} 条,已完成 {len(done)} 条,本轮待跑 {len(todo)} 条") with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: futures = {pool.submit(worker, it): it for it in todo} for fut in as_completed(futures): it = futures[fut] try: k = fut.result() done.add(k) save_state(done) print("完成", it["id"]) except Exception as e: print("失败", it["id"], e) if __name__ == "__main__": main()

这份脚本里有几个设计点是刻意加进去的,不是凑字数。

内容哈希做状态键,而不是用序号。这样你把提示词文件改了、顺序调了、加了几条,已经跑过的部分不会重复跑。我第一次写的时候用列表下标当键,结果中间插了一条,整个归档全乱套,重跑了一遍,白烧了不少额度。

状态文件每完成一条就落一次盘。别攒到最后统一写,脚本跑到一半崩了,前面全白干。

并发控制在 4,这个数字不是随便定的,下面一节会细讲。

重试针对的是 429 和 5xx,这两种是典型的“等一下再来就行”。4xx 里的参数错误、鉴权失败不要去重试,重试一万次结果一样,只会浪费时间。

4. 关键细节与参数:让批量导出真正稳住

代码能跑通只是及格线,能跑稳才算真的能用。这一节讲几个我反复调过、确实影响成败的地方。

4.1 并发数和限流,一定要算而不是猜

很多人写批量脚本,上来就开 32 个线程,跑十秒钟开始大批量报错,然后加 retry,然后雪崩。根源在于没算过账。

假设你要跑 800 条提示词,每条输入平均 200 token,输出平均 400 token。单条的 token 消耗量是 600,总共 48 万 token。这个量级本身不夸张,问题在速率。如果你把并发开到 20,每条请求平均耗时 3 秒,那么瞬时速率大概是 6.7 QPS。如果接口侧的速率限制比这个低,就会开始 429。

稳妥的做法是把并发压到 4 到 8 之间,然后给重试加上指数退避。所谓指数退避,就是失败后等 1.5 秒、3 秒、6 秒、12 秒、24 秒,而不是固定等 1 秒。再加一点 0 到 0.8 秒的随机抖动,避免所有失败的请求在同一时刻集体重试。

按并发 4、单条 5 秒估算,800 条需要 800 ÷ 4 × 5 = 1000 秒,大约 17 分钟。这个速度对绝大多数场景是够用的。跑一晚上能跑几万条,急什么呢。

提示:如果你的任务里混合了长输出和短输出,最好按预估输出长度分桶,长任务单独放一个小并发池,别让一两条长任务拖住整个队列。

4.2 多模态内容怎么落地成本地文件

纯文本好办,直接写字符串。图片、音频、PDF 这些就得单独处理。

接口返回里,多模态数据通常有两种形态。一种是内联的 base64,字段名一般是inlineData,带着mimeTypedata两个属性。这种最省事,把 base64 解码写成二进制文件就行。另一种是引用型的,字段名一般是fileData,给的是文件标识或者带签名的地址,这种需要你当场发起一次下载,把二进制存到本地,因为那个地址是有有效期的。

import base64, pathlib, mimetypes def dump_parts(parts, out_dir: pathlib.Path, prefix: str): out_dir.mkdir(parents=True, exist_ok=True) saved = [] for i, p in enumerate(parts): inline = p.get("inlineData") if not inline: continue mime = inline.get("mimeType", "application/octet-stream") ext = mimetypes.guess_extension(mime) or ".bin" data = base64.b64decode(inline["data"]) fp = out_dir / f"{prefix}_part{i}{ext}" fp.write_bytes(data) saved.append(str(fp)) return saved

有个判断上的细节:先看inlineData有没有,没有再看fileData。因为同一段内容在不同接口版本里可能以不同形态出现,两个都写一遍最省心。

还有个阈值要注意,内联数据是有体积上限的,超过之后接口会改用引用方式返回。所以你不能假设“一定有 base64”,得两条路都准备好。

4.3 断点续传和去重,靠哈希不靠时间

前面脚本里已经用了内容哈希,这里再强调一次为什么。

用时间戳做去重的问题在于,同一批提示词你今天跑一遍、明天跑一遍,时间戳不一样,会被当成新任务,于是库里出现两份内容几乎相同的结果,你还分不清哪份是新的。用序号做去重的问题在于,中间插一条就全乱。

内容哈希的思路是:把“提示词的文本内容 + 稳定的标识”拼起来算哈希,只要这个组合没变,就认为它已经完成过。这样你调整顺序、重命名、插入新条目,都不影响已完成的判断。

一个小的改进是把模型名和关键参数也拼进哈希。这样你在同一个脚本里跑两个不同的模型做对比时,两边的归档不会互相覆盖。

4.4 目录结构和命名,决定了你以后能不能找得到

我见过太多人归档完之后自己都找不着东西的。命名这事儿,一开始花十分钟定规矩,后面能省几百个小时。

文件名按日期_来源_标识_哈希前六位的结构来,比如20250312_aistudio_p001_9f3a21.md。日期保证自然排序,来源区分不同渠道,标识方便人肉定位,哈希前缀保证唯一。分区目录按raw放原始 JSON、md放可读文本、assets放多模态文件来分。原始 JSON 别删,它是你在数据出问题时唯一能回去查的东西。

还有一个习惯我强烈建议:在每份 Markdown 头部写一段元信息,包括模型名、关键参数、生成时间、提示词原文。三个月后你回头看,光看输出内容根本想不起来当时是怎么跑出来的。

5. 常见问题与排查技巧实录

这一节我按自己踩过的坑整理成速查表,你可以对着查。

现象常见原因排查思路处理办法
脚本抓不到任何请求注入时机晚于页面初始化在 Network 面板确认请求确实发生了,再看脚本是否在 document-start 阶段执行提前注入时机,或改用声明式注入并指定主世界
抓到内容只有一半命中流式返回,读的时候还没结束看原始响应是不是多行 JSON改成完整拼接后再解析,或监听流结束事件
导出的文字里混进按钮文案走的是 DOM 抓取路线检查是否有“复制”“展开”这类固定文本清洗时按固定词黑名单过滤,或改用接口路线
图片导出后打不开存的是 HTML 而不是二进制用文本编辑器打开看头部确认是 base64 解码还是需要二次下载
跑了几十条后大面积失败触发速率限制看错误码是不是 429降并发、加指数退避和抖动
重跑时重复写文件用了序号或时间戳做去重键检查状态文件的键生成逻辑改成内容哈希
中途崩了要全部重跑状态没有及时落盘检查写入频率每完成一条就写一次状态
换了模型后归档互相覆盖哈希里没包含模型和参数检查哈希输入把模型名、温度等关键参数拼进去
打开旧归档发现图片全裂存的是带签名的临时地址检查文件里是不是 URL当场下载二进制,别存链接
导出文件在别的编辑器里乱码编码不统一检查写入时是否指定了编码统一用 UTF-8 写入

再补几条表格里放不下、但很值钱的经验。

先跑十条再跑一千条。这个顺序千万别反。先拿十条验证脚本,确认目录结构、文件命名、内容格式都符合预期,再放开跑。我见过最惨的一次是有人直接开了五百条,跑完发现文件名里带了个非法字符,在系统里全是乱码,只能全部重来。

给脚本加个干跑模式。就是只打印“我打算跑哪些任务”,不真的发请求。跑之前看一眼待办列表对不对,比事后补救便宜太多。

日志要写文件,不要只打屏幕。终端滚起来之后,出问题的那条你根本找不回来。最简单的做法是把每个任务的 id、状态、耗时、错误信息追加写到一个日志文件里,一条一行,出问题直接搜。

注意结果的可复现性。如果你的任务需要别人也能跑出一样的结果,那温度之类的随机参数要固定下来,并且记录在元信息里。不然别人复现出来的效果跟你差很远,会怀疑你在编数据。

清理比导出更难。导出只是把数据搬出来,真正费时间的是清洗和归类。所以导出的时候就顺手把标签、分类、时间戳一起写进文件名和元信息里,后面能省掉一轮人工整理。

6. 踩过的坑和几条不写在文档里的体会

说几条只有自己动手才会明白的事。

第一,别迷信“一键”。任何声称能一键搞定所有平台的导出工具,本质上都是在赌目标页面的结构短期内不变。这类工具的价值在于省事,不在于稳定。如果你的数据是长期资产,早晚要自己接管这条管道。我现在的做法是:用现成工具做一次性搬运,同时自己维护一份结构化的提示词文件,让本地文件成为真正的源头,页面只是执行环境。

第二,原始数据一定要留。我现在的目录结构里,raw那个文件夹是只增不删的。哪怕 Markdown 已经写得很漂亮了,原始 JSON 也留着。因为你永远不知道哪天会需要一个字段——也许是 token 计数,也许是安全过滤的标记,也许是模型版本号。清洗脚本随时可以重写,原始数据丢了就真没了。

第三,导出这件事真正的成本不在技术,在纪律。工具搭好只是第一步,能不能坚持每次跑完就归档、能不能把命名规则执行下去、能不能在改提示词的时候顺手更新版本记录,这些决定了三个月后你手里是一份能用的资产还是一堆垃圾。我吃过这个亏,前期图省事随手存,后面想找某一条特定的输出,翻了两个小时。

第四,关于多模态和长会话,别一次性全量拉。长会话导出的时候内存占用会很明显,尤其是有大量图片的那种。我的做法是分批处理,每处理完一批就把缓冲区清掉,落盘之后再继续。这个改动看着不起眼,但把一次卡死变成了稳定跑完。

第五,如果你的提示词库在持续增长,最好给导出脚本加一条“增量同步”的能力,只处理新增和修改过的条目。前面那个基于内容哈希的状态文件已经把地基打好了,你只需要在每次运行前重新加载一遍提示词文件,脚本会自动算出差异部分。跑一次全量可能要二十分钟,跑一次增量可能只要十秒,长期下来差别巨大。

最后说个容易被忽略的方向:导出只是管道的一端,另一端是导入。如果你已经有一套自己的归档格式,完全可以让脚本同时支持从归档反推回提示词列表,这样你在本地编辑完提示词之后,又能一键同步回执行环境。这套双向管道搭起来之后,AI Studio 就真正变成了一个可以随时替换的执行器,而不是你数据资产的唯一容器。这个扩展方向的想象空间,比单纯讨论“能不能批量导出”要大得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 11:07:47

Typesense迁移实战:比ES快5倍的轻量搜索与向量召回

ES 这玩意&#xff0c;部署过的人都懂&#xff1a;单机跑起来容易&#xff0c;想跑稳、跑快、跑便宜却很难。我最近把一套内容检索和商品检索的业务从 Elasticsearch 迁到了一个更轻的搜索引擎 Typesense&#xff0c;在即时搜索、前缀匹配和向量召回这几类查询里&#xff0c;P9…

作者头像 李华
网站建设 2026/9/18 11:04:58

编译原理第八章代码生成与优化:基本块、DAG与活跃性分析实战解析

如果你正在啃《编译原理》也就是大家常说的龙书&#xff0c;并且刚好卡在第八章&#xff0c;那你应该能理解我的感觉。前七章还在讨论词法、语法、中间代码生成&#xff0c;虽然也有难度&#xff0c;但至少处理的还是“程序长什么样”的问题&#xff1b;到了第八章&#xff0c;…

作者头像 李华
网站建设 2026/9/18 11:04:30

接口变慢怎么办?APM与链路追踪的完整排查实战

接口变慢这事&#xff0c;干过后端的都懂。明明昨天还好好的&#xff0c;今天一上班监控就报“获取用户详情”接口p99从120ms飙到3秒&#xff0c;用户侧已经在群里炸了。你第一反应是打开服务器日志&#xff0c;结果翻了几百MB日志&#xff0c;只看到一堆正常返回&#xff0c;根…

作者头像 李华
网站建设 2026/9/18 11:04:26

【ComfyUI】图像反推描述词总结

在 ComfyUI 的工作流中,图像反推描述词是一条关键通道。它决定了从图像中提取出的语言信息是否精准、生动,也影响着后续提示词生成与再创作的质量。正因如此,社区里围绕这一功能衍生出了多种模型与节点,每一种都有自己的特色:有的追求稳健与客观,有的注重细节与叙事,有的…

作者头像 李华
网站建设 2026/9/18 11:02:45

amlogic-s9xxx-armbian:把闲置电视盒子变成私人 Linux 服务器

amlogic-s9xxx-armbian&#xff1a;把闲置电视盒子变成私人 Linux 服务器 【免费下载链接】amlogic-s9xxx-armbian Supports running Armbian on Amlogic, Allwinner, and Rockchip devices. Support a311d, s922x, s905x3, s905x2, s912, s905d, s905x, s905w, s905, s905l, r…

作者头像 李华
网站建设 2026/9/18 11:00:23

审核心脏 3D 模型,CHOP 备注生成器填 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华