title: "Mini Demo:CSV → 自动选图表,BYOK 模式的工程实践" slug: csv-smart-charts publishedAt: 2026-04-30 excerpt: Bring Your Own Key 的好处与陷阱:LLM 选图表 / 写 spec / 校验数据维度,以及为什么不要让模型自己执行 SQL。 tags: [frontend, llm, byok] readingMin: 10 wordCount: 1520 draft: false
BYOK 是给开发者准备的礼物
/works/csv-charts 这个 demo 让用户上传 CSV,LLM 自动判断该用什么图表(bar/line/pie),生成 chart spec,然后用 recharts 渲染。模型用 claude-haiku-4-5,够用且便宜。
但这个 demo 最特别的地方不是"AI 选图表",而是它跑在 BYOK(Bring Your Own Key)模式下 —— 用户把自己的 Anthropic API key 填到一个输入框里,Key 只在浏览器 localStorage,前端直接调 Anthropic API,没有任何后端中转。
BYOK 在 demo 站里几乎是唯一靠谱的方式:
- 不烧钱:每个访问者用自己的 key,作品集主理人不付费
- 不存储凭证:Key 不进任何后端,不存在数据泄露责任
- 开发者友好:目标用户(招聘官 / 同行)大概率自己有 key
代价是工程上要处理几个非显然的问题。这篇把整个 demo 的 BYOK 链路展开。
第一坑:浏览器直接调 Anthropic API
/v1/messages 默认不允许浏览器直接调用(CORS)。一个普通的 fetch() 会被浏览器挡掉。
Anthropic 提供了一个开关让你显式同意"浏览器直连":
- 在 Anthropic Console 的 API key 设置里勾上 Allow browser usage
- 在请求头加
anthropic-dangerous-direct-browser-access: true
请求长这样:
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": userKey,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-haiku-4-5",
max_tokens: 1024,
messages: [
{ role: "user", content: prompt },
],
}),
});dangerous 这个词不是吓人的,它就是字面意思:浏览器请求会带 user-agent + referer + 用户 IP,Anthropic 想确保你明白这一点。对 BYOK demo 来说完全合理,因为用户用的就是自己的 key、自己的浏览器、自己的 IP。
给用户的预警提示
这条 CORS 开关用户不开就整个 demo 无法工作。我们在页面上方加了一个 callout:
<div role="note" className="rounded-md border border-border bg-card/60 p-3 text-xs">
<AlertTriangle className="inline h-3.5 w-3.5" />
首次使用前请在
<a href="https://console.anthropic.com/" target="_blank" rel="noopener noreferrer">
Anthropic Console
</a>
打开 <code>Allow browser usage</code>。不填 key 也能用,会走启发式 fallback。
</div>**"不填 key 也能用"**这点接下来讲。
第二步:启发式 fallback
LLM 选图表当然好,但万一 key 没填、key 填错、调用失败,demo 不应该死给用户看。所以我们写了一个 ~30 行的启发式 fallback,根据 CSV 列的数据类型猜图表:
function heuristicChart(columns: ColumnInfo[]): ChartSpec {
const num = columns.filter((c) => c.type === "number");
const cat = columns.filter((c) => c.type === "category");
const date = columns.find((c) => c.type === "date");
// 至少 2 个数值列 + 1 个分类列 → bar / line(有 date 优先 line)
if (num.length >= 2 && cat.length >= 1) {
return date
? { kind: "line", xField: date.name, yFields: num.map((c) => c.name) }
: { kind: "bar", xField: cat[0].name, yFields: num.map((c) => c.name) };
}
// 1 个数值列 + 1 个分类列 → pie
if (num.length === 1 && cat.length >= 1) {
return { kind: "pie", categoryField: cat[0].name, valueField: num[0].name };
}
// 默认 bar
return { kind: "bar", xField: columns[0].name, yFields: [columns[1]?.name] };
}规则简陋但够用:bar / line / pie 三选一,只看列数量与类型。LLM 的存在让规则可以更聪明,但不应让 fallback 不存在。
第三步:让 LLM 输出 chart spec,不让它执行 SQL
这是 BYOK demo 最容易踩的坑:你给 LLM 看完整的 CSV 数据,然后让它"返回结果"。模型可能给你计算好的数字,但这个数字完全无法验证 —— LLM 算错了你也不知道。
更糟的是有人会让 LLM 生成 SQL 让前端 sandbox 跑。SQL 注入 + 沙箱逃逸是个常年话题,demo 站不值得引入这个风险面。
我们的设计:LLM 只输出 chart spec(讨论用哪个图表 + 哪几列),实际聚合 / 计数 / 绘图全部在前端用 papaparse + recharts 干。
// 给 LLM 的 prompt(简化版)
const prompt = `
我有一份 CSV,头部和前 10 行如下:
${csvSample}
请只返回一个 JSON,描述应该用什么图表:
{
"kind": "bar" | "line" | "pie",
"xField": "<列名>", // bar/line
"yFields": ["<列名>"], // bar/line
"categoryField": "...", // pie
"valueField": "...", // pie
"reason": "<一句话理由>"
}
不要返回数据本身,只返回 spec。
`;返回值用 zod schema 校验:
const ChartSpec = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("bar"),
xField: z.string(),
yFields: z.array(z.string()).min(1),
reason: z.string(),
}),
z.object({
kind: z.literal("line"),
xField: z.string(),
yFields: z.array(z.string()).min(1),
reason: z.string(),
}),
z.object({
kind: z.literal("pie"),
categoryField: z.string(),
valueField: z.string(),
reason: z.string(),
}),
]);模型返回非合法 JSON 或字段不存在 → 抛错 → 走启发式 fallback。模型不可信,zod 才可信。
TypeScript 陷阱:discriminated union 在 JSX 里 narrow 失败
写到这里有个非常具体的坑:ChartSpec 是 discriminated union(bar/line/pie 各自字段不同),在 JSX 里三元表达式 narrow 经常丢类型:
// ❌ 这样写 pie 分支拿不到 categoryField
return spec.kind === "pie"
? <PieChart data={...} dataKey={spec.categoryField} />
: <BarChart data={...} xKey={spec.xField} />;TS 看到 JSX 同一个 return 里嵌套三元,narrow 不能跨表达式传播。解法是把每个分支抽出来用 switch:
function ChartByType({ spec, data }: { spec: ChartSpec; data: Row[] }) {
switch (spec.kind) {
case "bar":
return <BarChart data={data} xKey={spec.xField} yKeys={spec.yFields} />;
case "line":
return <LineChart data={data} xKey={spec.xField} yKeys={spec.yFields} />;
case "pie":
return <PieChart data={data} categoryKey={spec.categoryField} valueKey={spec.valueField} />;
}
}switch 让 narrow 在每个 case 里完整传播,JSX 内不要嵌三元。
第四步:Key 的本地存储与隐藏
Key 不进任何后端,但仍要持久化(否则刷新就丢)。用 localStorage:
const KEY = "csv-charts:anthropic-key";
function loadKey(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(KEY);
}
function saveKey(k: string) {
window.localStorage.setItem(KEY, k);
}输入框 <input type="password">,UI 上 mask 掉。前端做一个最低限度的格式校验,防止用户填错系统 key:
const KEY_REGEX = /^sk-ant-[A-Za-z0-9_-]{20,}$/;模型 ID 也写死在前端 claude-haiku-4-5,单点改,Sprint 3 之前不打算引一个"模型下拉"。
BYOK 适合谁,不适合谁
总结一下我们的判断:
- 适合:开发者面向的 demo、内部工具、招聘作品集
- 不适合:面向终端用户的产品(用户不知道也不应该知道什么是 API key)
- 不适合:有付费层级的产品(扣钱要扣自己的,不能让用户为你的服务付 Anthropic 的钱)
我们这个 demo 站本质就是作品集,BYOK 是诚实地告诉招聘官:我知道怎么让 AI 跑活,但我不让你为我的演示付费。
想看代码,在
apps/web/app/works/csv-charts/page.tsx。整页含上传 / parse / 调 LLM / 渲染 ~500 行,无任何后端依赖。