☰
Codex 100个真实案例 - 用AI做中英双语对照文档生成器(论文翻译利器)
2026/9/29 8:26:02 网站建设 项目流程

1. 为什么我要用 Codex 做一个中英双语对照文档生成器

读英文论文最难受的地方不是看不懂单词,而是看完一段英文,脑子里还得再"翻译"一遍才能理解。尤其是几十页的 PDF,来回切换翻译软件,段落对不上、术语前后不一致,读到后面已经忘了前面在讲什么。

我试过直接丢给翻译工具整篇翻,结果更糟:公式被翻乱、代码块被当成正文、专业术语一会儿"神经网络"一会儿"类神经网络",最后还得人工逐段校对,比自己读还累。

所以这次我用 Codex 从零搭了一个中英双语对照文档生成器,目标很明确:上传英文文档(.txt / .md / .docx / .pdf),自动逐段翻译,输出左英右中的对照 PDF,段落一一对齐,术语表可自定义,支持批量处理。它适合科研人员读论文、留学生看教材、技术同学啃英文文档这几类场景。

整篇我会把可复制的 Codex 配置骨架、TaoToken 统一 Key/API 通道接入步骤、双语对照输出的验证动作,以及我踩过的报错排查清单都写清楚。你跟着做,能跑出一个真正能用的工具,而不是一个玩具 demo。

2. TaoToken 前置准备:统一 Key 与 API 通道

在写翻译服务之前,先把模型通道打通。这个生成器的翻译质量完全取决于背后的大模型,所以我用 TaoToken 作为统一入口,一个 Key 就能调用多种模型,省得在代码里维护一堆不同的 API 地址和鉴权方式。

TaoToken 的定位是给开发者和 AI 应用提供统一的模型调用通道,兼容 OpenAI 接口格式,所以我们的translator.js里可以直接用chat/completions的标准写法,不用为每家模型单独适配。

你需要做三件事:

第一,注册并登录后进入控制台,创建一个 API Key。地址是https://taotoken.net/api-keys,创建后复制保存,后面写进.env。

第二,确认你要用的模型名。TaoToken 支持多种主流模型,翻译这种任务我建议用中英能力均衡、上下文较长的模型,术语一致性会更好。

第三,把 API 基地址记下来:https://taotoken.net/api。注意这个地址不带任何查询参数,直接作为base_url使用。

注意:API Key 只放在服务端.env里,绝对不要写进前端代码或提交到 Git 仓库。前端只调用你自己的后端接口,由后端去请求模型。

如果你还没决定用哪个模型,可以先到模型对话页面手动试几段论文摘要,对比一下翻译风格,再定下来写进配置。地址是https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite。

3. 可复制的 Codex 配置骨架(含 config.toml)

Codex 的强项是"用自然语言驱动工程",但前提是项目骨架要清晰。我先把项目结构定下来,再让 Codex 逐个模块实现,避免一次性生成太多代码导致质量下降。

3.1 项目初始化与目录结构

在终端里让 Codex 创建项目:

请帮我创建一个中英双语对照文档生成器项目,名为 bilingual-doc-generator。 后端用 Node.js + Express,前端用 Vue 3 + Element Plus。 后端放在 server 目录,前端放在 client 目录。 后端需要支持文件上传、翻译处理、PDF 生成。 前端需要支持拖拽上传、实时预览、进度展示。

Codex 会执行类似下面的初始化命令:

mkdir bilingual-doc-generator && cd bilingual-doc-generator mkdir -p server/src/{routes,services,utils} mkdir -p server/uploads server/output server/glossaries cd server && pnpm init pnpm add express multer cors ws dotenv pnpm add pdfkit mammoth pdf-parse marked archiver pnpm add -D nodemon cd .. && pnpm create vue@latest client -- --template vue cd client && pnpm add element-plus @element-plus/icons-vue axios

生成的目录结构大致是这样:

bilingual-doc-generator/ ├── server/ │ ├── src/ │ │ ├── routes/ # upload / translate / export 路由 │ │ ├── services/ # parser / translator / pdfGenerator / glossary │ │ ├── utils/ # textSplitter 等工具 │ │ └── app.js # 应用入口 │ ├── uploads/ # 上传文件 │ ├── output/ # 输出 PDF │ ├── glossaries/ # 术语表 │ └── package.json ├── client/ │ └── src/ │ ├── views/Home.vue │ └── components/ └── README.md

3.2 Codex 的 config.toml 配置

Codex 的配置文件放在用户目录下的.codex/config.toml。我这份配置的核心思路是:把模型通道指向 TaoToken,让 Codex 在生成代码和后续调试时都走同一条通道。

# ~/.codex/config.toml model = "gpt-4o" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"

然后在 shell 里导出 Key(Windows 用set或系统环境变量):

export TAOTOKEN_API_KEY="sk-你的TaoToken密钥"

这样 Codex 在会话里就能直接调用模型。如果你打算长期用 Codex 做编码和 Agent 任务,建议单独开一个 Coding Plan,额度更划算,地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。

3.3 后端 .env 配置

后端服务自己也要调模型,所以单独写一份.env:

# server/.env LLM_BASE_URL=https://taotoken.net/api LLM_API_KEY=sk-你的TaoToken密钥 LLM_MODEL=gpt-4o PORT=3200 MAX_FILE_SIZE=20971520

注意这里LLM_BASE_URL用的是https://taotoken.net/api,代码里再拼/v1/chat/completions,这样切换模型时只改.env,不动业务代码。

4. 核心模块实现:解析、翻译、术语表、PDF

骨架搭好后,逐个模块让 Codex 实现。这里我把关键代码贴出来,你可以直接对照。

4.1 文档解析服务 parser.js

翻译的第一步是把各种格式的文档正确解析成纯文本段落。让 Codex 实现时,我特别强调要保留段落结构、过滤空段、标记段落类型:

// server/src/services/parser.js const fs = require('fs'); const path = require('path'); const mammoth = require('mammoth'); const pdfParse = require('pdf-parse'); class DocumentParser { async parse(filePath) { const ext = path.extname(filePath).toLowerCase(); let rawText = ''; switch (ext) { case '.txt': rawText = fs.readFileSync(filePath, 'utf-8'); break; case '.md': rawText = this.stripMarkdown(fs.readFileSync(filePath, 'utf-8')); break; case '.docx': rawText = (await mammoth.extractRawText({ path: filePath })).value; break; case '.pdf': rawText = (await pdfParse(fs.readFileSync(filePath))).text; break; default: throw new Error(`不支持的文件格式: ${ext}`); } return this.splitIntoParagraphs(rawText); } stripMarkdown(content) { return content .replace(/^#{1,6}\s+(.+)$/gm, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/`(.+?)`/g, '$1') .replace(/```[\s\S]*?```/g, '') .replace(/!\[.*?\]\(.*?\)/g, '') .replace(/\[(.+?)\]\(.*?\)/g, '$1'); } splitIntoParagraphs(text) { const raw = text.split(/\n{2,}/); const paragraphs = []; let index = 0; for (const para of raw) { const trimmed = para.trim(); if (!trimmed || trimmed.length < 2) continue; paragraphs.push({ index: index++, text: trimmed, type: this.detectType(trimmed), }); } return paragraphs; } detectType(text) { if (text.length < 100 && !text.endsWith('.')) return 'heading'; if (/^[\d]+\.|^[-*]/.test(text)) return 'list'; if (/^(function|const|let|var|import|class|def)\s/.test(text)) return 'code'; return 'paragraph'; } } module.exports = new DocumentParser();

给段落打类型标记很关键:代码段不翻译、标题保持简洁,后续差异化处理全靠这个字段。

4.2 翻译服务 translator.js

翻译是核心。这里对接 TaoToken 的 OpenAI 兼容接口,同时做并发控制和重试:

// server/src/services/translator.js const https = require('https'); const http = require('http'); class TranslatorService { constructor() { this.baseUrl = process.env.LLM_BASE_URL || 'https://taotoken.net/api'; this.apiKey = process.env.LLM_API_KEY; this.model = process.env.LLM_MODEL || 'gpt-4o'; this.maxConcurrency = 5; this.maxRetries = 3; } async translateParagraphs(paragraphs, glossary = {}, onProgress = null) { const results = new Array(paragraphs.length); let completed = 0; const tasks = paragraphs.map((para, i) => async () => { if (para.type === 'code') { results[i] = { ...para, translation: para.text, skipped: true }; } else { results[i] = { ...para, translation: await this.translateWithRetry(para.text, glossary), skipped: false, }; } completed++; if (onProgress) { onProgress({ total: paragraphs.length, completed, percent: Math.round((completed / paragraphs.length) * 100), }); } }); await this.runWithConcurrency(tasks); return results; } async translateWithRetry(text, glossary, attempt = 1) { try { return await this.callAPI(text, glossary); } catch (err) { if (attempt < this.maxRetries) { await this.sleep(1000 * attempt); return this.translateWithRetry(text, glossary, attempt + 1); } return `[翻译失败] ${text.substring(0, 50)}...`; } } async callAPI(text, glossary) { let glossaryPrompt = ''; const entries = Object.entries(glossary); if (entries.length > 0) { const terms = entries.map(([en, zh]) => `"${en}" → "${zh}"`).join('\n'); glossaryPrompt = `\n\n请严格遵守以下术语表翻译:\n${terms}`; } const systemPrompt = `你是一个专业的英中翻译专家。请将以下英文文本翻译为中文。 要求: 1. 翻译准确、流畅、自然 2. 保留原文的段落结构和格式 3. 专业术语翻译精准 4. 不要添加任何解释或注释,只输出翻译结果 5. 数字、公式、代码保持原样不翻译${glossaryPrompt}`; const body = JSON.stringify({ model: this.model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: text }, ], temperature: 0.3, max_tokens: 4096, }); const url = new URL(`${this.baseUrl}/v1/chat/completions`); const client = url.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { const req = client.request( { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, 'Content-Length': Buffer.byteLength(body), }, }, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { try { const json = JSON.parse(data); if (json.choices && json.choices[0]) { resolve(json.choices[0].message.content.trim()); } else { reject(new Error('API 返回格式异常')); } } catch (e) { reject(new Error(`JSON 解析失败: ${e.message}`)); } }); } ); req.on('error', reject); req.write(body); req.end(); }); } async runWithConcurrency(tasks) { const executing = new Set(); for (const task of tasks) { const p = task().then(() => executing.delete(p)); executing.add(p); if (executing.size >= this.maxConcurrency) { await Promise.race(executing); } } await Promise.all(executing); } sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } } module.exports = new TranslatorService();

几个设计点值得说明:temperature: 0.3保证翻译一致性;术语表直接注入 system prompt,比事后替换更自然;并发上限 5 避免触发限流。

4.3 术语表服务 glossary.js

专业文档翻译最头疼的就是术语不统一。术语表服务支持多领域、增删改查、CSV 导入:

// server/src/services/glossary.js const fs = require('fs'); const path = require('path'); class GlossaryService { constructor() { this.dir = path.join(__dirname, '../../glossaries'); if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir, { recursive: true }); this.cache = new Map(); this.loadBuiltin(); } loadBuiltin() { const builtin = { 'computer-science': { 'Machine Learning': '机器学习', 'Deep Learning': '深度学习', 'Neural Network': '神经网络', 'Natural Language Processing': '自然语言处理', 'Reinforcement Learning': '强化学习', 'Gradient Descent': '梯度下降', 'Overfitting': '过拟合', 'Attention Mechanism': '注意力机制', 'Fine-tuning': '微调', 'Embedding': '嵌入', }, medical: { 'Randomized Controlled Trial': '随机对照试验', 'Placebo': '安慰剂', 'Biomarker': '生物标志物', 'Efficacy': '疗效', 'Prognosis': '预后', }, }; for (const [domain, terms] of Object.entries(builtin)) { this.cache.set(domain, terms); this.save(domain, terms); } } get(domain) { if (this.cache.has(domain)) return { ...this.cache.get(domain) }; const file = path.join(this.dir, `${domain}.json`); if (fs.existsSync(file)) { const terms = JSON.parse(fs.readFileSync(file, 'utf-8')); this.cache.set(domain, terms); return { ...terms }; } return {}; } merge(domains) { const merged = {}; for (const d of domains) Object.assign(merged, this.get(d)); return merged; } addTerm(domain, en, zh) { const g = this.get(domain); g[en] = zh; this.cache.set(domain, g); this.save(domain, g); return g; } importCSV(domain, csv) { const g = this.get(domain); for (const line of csv.split('\n')) { const t = line.trim(); if (!t || t.startsWith('#')) continue; const [en, zh] = t.split(',').map((s) => s.trim().replace(/^"(.*)"$/, '$1')); if (en && zh) g[en] = zh; } this.cache.set(domain, g); this.save(domain, g); return g; } save(domain, glossary) { fs.writeFileSync( path.join(this.dir, `${domain}.json`), JSON.stringify(glossary, null, 2), 'utf-8' ); } } module.exports = new GlossaryService();

4.4 PDF 双语对照生成 pdfGenerator.js

这是最有成就感的部分——左英右中,中间一条分割线,页眉页脚齐全:

// server/src/services/pdfGenerator.js const PDFDocument = require('pdfkit'); const fs = require('fs'); class PDFGeneratorService { constructor() { this.pageWidth = 595.28; this.pageHeight = 841.89; this.margin = 40; this.columnGap = 20; this.headerHeight = 50; this.footerHeight = 40; const usable = this.pageWidth - 2 * this.margin - this.columnGap; this.columnWidth = usable / 2; this.chineseFont = '/System/Library/Fonts/PingFang.ttc'; this.fontSize = 10; this.headingSize = 14; } async generate(paragraphs, title, outputPath) { return new Promise((resolve, reject) => { const doc = new PDFDocument({ size: 'A4', margins: { top: this.margin + this.headerHeight, bottom: this.margin + this.footerHeight, left: this.margin, right: this.margin, }, bufferPages: true, }); const stream = fs.createWriteStream(outputPath); doc.pipe(stream); if (fs.existsSync(this.chineseFont)) { doc.registerFont('Chinese', this.chineseFont); } let y = this.margin + this.headerHeight; let pageNum = 1; this.drawHeader(doc, title); for (const para of paragraphs) { if (para.skipped) continue; const isHeading = para.type === 'heading'; const size = isHeading ? this.headingSize : this.fontSize; const leftH = this.measure(doc, para.text, size, 'Helvetica'); const rightH = this.measure(doc, para.translation, size, 'Chinese'); const rowH = Math.max(leftH, rightH) + 15; const maxY = this.pageHeight - this.margin - this.footerHeight; if (y + rowH > maxY) { this.drawFooter(doc, pageNum); doc.addPage(); pageNum++; y = this.margin + this.headerHeight; this.drawHeader(doc, title); } doc.font(isHeading ? 'Helvetica-Bold' : 'Helvetica').fontSize(size); doc.text(para.text, this.margin, y, { width: this.columnWidth, lineGap: 3 }); const dividerX = this.margin + this.columnWidth + this.columnGap / 2; doc .save() .moveTo(dividerX, y) .lineTo(dividerX, y + rowH - 10) .strokeColor('#cccccc') .lineWidth(0.5) .stroke() .restore(); const rightX = this.margin + this.columnWidth + this.columnGap; doc.font('Chinese').fontSize(size); doc.text(para.translation, rightX, y, { width: this.columnWidth, lineGap: 3 }); y += rowH; } this.drawFooter(doc, pageNum); doc.end(); stream.on('finish', () => resolve(outputPath)); stream.on('error', reject); }); } drawHeader(doc, title) { doc .save() .font('Chinese') .fontSize(9) .fillColor('#888888') .text(title, this.margin, this.margin, { width: this.pageWidth - 2 * this.margin, align: 'center', }); const lineY = this.margin + this.headerHeight - 10; doc .moveTo(this.margin, lineY) .lineTo(this.pageWidth - this.margin, lineY) .strokeColor('#dddddd') .lineWidth(0.5) .stroke() .restore(); } drawFooter(doc, pageNum) { const y = this.pageHeight - this.margin - 15; doc .save() .font('Helvetica') .fontSize(8) .fillColor('#888888') .text(`- ${pageNum} -`, this.margin, y, { width: this.pageWidth - 2 * this.margin, align: 'center', }) .restore(); } measure(doc, text, size, font) { doc.font(font).fontSize(size); return doc.heightOfString(text, { width: this.columnWidth, lineGap: 3 }); } } module.exports = new PDFGeneratorService();

5. 验证请求与成功结果

代码写完后,必须验证整条链路能跑通。我分三步验证。

5.1 启动服务

# 启动后端 cd server && pnpm dev # 输出: 双语文档生成器服务已启动: http://localhost:3200 # 新终端启动前端 cd client && pnpm dev

5.2 用 curl 验证翻译接口

先单独验证 TaoToken 通道是否通:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "gpt-4o", "messages": [ {"role": "system", "content": "你是英中翻译专家,只输出译文。"}, {"role": "user", "content": "Attention is all you need."} ], "temperature": 0.3 }'

正常返回里choices[0].message.content应该是类似"注意力机制就是你所需要的一切"这样的中文。如果这里就报错,先排查 Key 和模型名,别急着往下走。

5.3 验证完整翻译流程

上传一个测试文档,调用翻译接口:

curl -X POST http://localhost:3200/api/translate \ -H "Content-Type: application/json" \ -d '{ "filePath": "/绝对路径/server/uploads/test.pdf", "fileName": "test.pdf", "glossaryDomains": ["computer-science"] }'

返回{ "success": true, "data": { "taskId": "xxx", "status": "processing" } }说明任务已创建。等几秒后查询状态:

curl http://localhost:3200/api/translate/status/你的taskId

成功时data.status为completed,data.paragraphs里每个段落都有text(英文)和translation(中文)两个字段,一一对应。最后下载 PDF:

curl -O http://localhost:3200/api/export/你的taskId

打开 PDF,应该看到左英右中、段落对齐、页眉有标题、页脚有页码。到这一步,整个生成器就跑通了。

6. 本篇常见报错排查清单

下面这些是我实际踩过的坑,按出现频率排序。

报错一:401 Unauthorized或invalid api key

原因基本是 Key 没读到或写错。检查.env里LLM_API_KEY是否和 TaoToken 控制台里的一致,注意不要有多余空格或引号。Node 里process.env读不到时,确认dotenv在app.js顶部就require了。

报错二:404 Not Found请求模型接口

多半是 base_url 拼错了。正确写法是https://taotoken.net/api加上/v1/chat/completions。如果你在.env里把/v1也写进 base_url,就会变成/v1/v1/...。统一约定:base_url 只到/api。

报错三:PDF 中文显示成方块或乱码

PDFKit 默认字体不含中文。确认registerFont('Chinese', ...)的字体路径存在。macOS 用/System/Library/Fonts/PingFang.ttc,Linux 服务器上要换成系统里实际有的中文字体,比如/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc。路径不存在时fs.existsSync会返回 false,字体就没注册上。

报错四:翻译到一半卡住或超时

通常是并发太高触发限流。把maxConcurrency从 5 降到 2 或 3,同时确认重试逻辑生效。另外长段落要先用textSplitter切分,单段超过 500 字符就按句号切,避免单次请求 token 超限。

报错五:Cannot find module 'archiver'

批量打包用到的archiver没装。在server目录执行pnpm add archiver。同理,mammoth、pdf-parse、pdfkit任何一个缺失都会在对应格式解析时报模块找不到。

报错六:WebSocket 连不上,进度条不动

检查前端ws://localhost:3200/ws?taskId=xxx的端口和后端一致,且后端WebSocketServer的path是/ws。如果前端跑在 Vite 的 5173 端口,跨端口连接 WebSocket 一般没问题,但要注意别被浏览器混合内容策略拦(http 页面连 ws 可以,https 页面必须连 wss)。

报错七:docx 解析出来全是空

mammoth.extractRawText对某些复杂排版的 docx 支持有限。如果文档里有大量文本框或表格,解析结果可能为空。这种情况建议先另存为纯文本或 PDF 再上传。

排查时记住一个原则:先单独验证 TaoToken 通道(第 5.2 节那条 curl),通道通了再查业务代码。大部分"翻译失败"其实卡在通道层,而不是解析或 PDF 层。

7. 继续完善与接入建议

跑通基础版后,这个工具还能继续长。比如加 OCR 支持扫描版 PDF、扩展多语言、加翻译记忆复用相似段落、多人协同校对。每一个扩展都可以通过给 Codex 一个清晰的提示词来实现。

如果你打算把它做成长期使用的工具,建议把模型调用统一收敛到 TaoToken 的 API Key 上,这样切换模型、调整额度都在一个地方管理。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有完整的接口说明和参数示例。需要管理多个 Key 或查看用量,去控制台https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite。

最后提醒一句:术语表是这个工具的灵魂。论文翻译质量的高低,八成取决于你有没有把领域术语喂对。先把你要读的那篇论文里的核心术语整理成 CSV 导进去,再跑翻译,效果会明显不一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询