这两年 AI 对话工具越来越普及之后,我遇到最多的需求并不是"帮我写一段代码"或者"总结一下这份文档",反而是很多人拿着一大段带表格的对话内容问我:怎么把这个结果变成 Excel 方便我交差?
其实大家都有类似的经历——让 AI 模型给你生成一份季度销售统计表,消息里确实整整齐齐地列出了产品名、销售额、环比增幅,但真要把它导到 Excel 里继续透视、筛选、做图表,就非常尴尬。复制粘贴到 Excel 往往变成一列挤一列,格式全部乱掉;让模型生成 CSV 代码,又得手动跑一遍脚本;更麻烦的是,数据一多,总不可能每次都在对话里复制来复制去。所以"从 API 调用、表格解析到自动化导出"这套链路,就成了让我反复折腾了很久、也踩了不少坑的课题。
这篇文章就完整盘一盘我自己在 DeepSeek 上做表格导出全链路的落地过程:调用 API 拿到数据只是起点,真正的核心在于怎么把模型返回的非结构化内容解析成干净的行列结构,怎么把解析结果稳定地写入 Excel / CSV,以及怎么做成定时自动跑的批处理链路。整个方案不挑框架、不依赖私有平台,Python 就能全部搞定。不管你是刚接触 API 调用的小白,还是想直接把这套流程接进自己分析管道里的开发者,应该都能从中找到可以直接复用的代码和思路。
1. 为什么"AI 给出表格"和"拿到真表格"之间隔着重重困难
先说说痛点,不然你可能觉得我在小题大做。AI 模型很擅长在对话里呈现"看起来是表格"的内容,比如常见的 Markdown 表格:
| 月份 | 销售额 | 环比 | |------|--------|------| | 1月 | 120万 | +8% | | 2月 | 132万 | +10%|这种格式人眼看着特别清楚,但它是为"阅读"设计的,不是为"数据处理"设计的。你把它原样复制到 Excel 里,大概率会遇到三类问题:
第一,不同单元格内容全部挤在一个 A1 单元格里,字段分隔符(|、空格)没有被识别成列;第二,中英文混排的时候,Excel 可能把"| 120万 |"整体当成文本,导致数字没法求和;第三,一旦表格行数很多,比如上百行,复制粘贴这种手工操作的出错率和时间成本一下子就上来了。有人可能会说:"那我让 AI 直接给 CSV 啊?"对,这是个思路,但随之而来的问题是:AI 在生成 CSV 时经常不按标准格式输出,列值里如果包含逗号或换行,它未必会恰当地加引号包裹。更常见的问题是,它生成的 CSV 文本被对话界面做了字符转义,真正的逗号分割符可能变成了全角逗号或者半角全角混合,解析起来又是一堆脏活儿。
所以当我认真考虑把这个需求工程化的时候,很快就意识到:如果让模型直接输出 CSV 文本,人肉去看没问题,机器去解析会非常痛苦;如果让它直接输出规范 JSON,再由我自己的代码去转 Excel,整条链路反而可控得多。这算是我做完之后首先想分享的一个判断。下面整个方案都是沿着这条思路展开的。
2. DeepSeek API 返回里的表格长什么样:一切的起点是拿到响应
2.1 调用 API 的基本姿势与超参选择
DeepSeek 的接口兼容 OpenAI 的调用形式,Python 里直接用 openai 库改一个 base_url 就行。这个细节很多文章讲过,但我在实际项目中还是遇到几次低级问题:如果环境变量没配好,或者 base_url 写成了类似平台三级域名,调用就会一直报连接错误;而这些其实跟模型本身没有任何关系。一个稳定能跑的调用代码长这样:
import os import json from openai import OpenAI client = OpenAI( api_key=os.environ.get("DEEPSEEK_API_KEY"), base_url="https://api.deepseek.com" ) def ask_deepseek(prompt: str, response_format: dict | None = None) -> str: kwargs = { "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是数据整理助手。"}, {"role": "user", "content": prompt} ], "temperature": 0.0, # 表格导出场景必须低温,杜绝随机发挥 "max_tokens": 4096 } # 如果平台支持结构化输出,加上 response_format 进一步约束 if response_format: kwargs["response_format"] = response_format resp = client.chat.completions.create(**kwargs) return resp.choices[0].message.content这里我的第一个经验是:temperature 一定不要给高。默认的 API 参数往往带一点随机性,写文案没问题,但做表格导出时,模型一旦在某一行数据上"自由发挥"改了个数字,你导出后的表格就是错的,而且非常难发现。什么数值转换、字段缺失,往往就藏在一次不低不高的 temperature 里。所以我直接把 temperature 固定成 0,宁可让结果显得"机械",也不能让它创造性发挥。
第二个经验是:如果模型支持 JSON 结构化输出,优先打开。DeepSeek 目前也支持 response_format 设置成 json_object 这类约束,这意味着返回的内容可以直接 json.loads(),不用去和 Markdown 里的 ```json 围栏搏斗。能用这个就用这个,能省掉后面一大部分麻烦。
2.2 拿回来的东西到底长什么样
先看一个最基础的返回,模型在对话里给的表格内容通常长这样:
当然可以,以下是各门店销售额统计表: | 门店 | Q1销量 | Q2销量 | 合计 | |------|--------|--------|------| | 华东 | 120 | 145 | 265 | | 华北 | 98 | 132 | 230 |表面上这是一个 Markdown 表格,但在 Python 变量里它就是一个普通字符串,里面混着换行、竖线、中文空格甚至可能还有多余的回车。你要是直接拿 split("|") 去切,会遇到各种边界情况:表头行和分隔行混进来、单元格首尾有空格、某些列值里可能真的包含了竖线(比如 "A|B" 这种型号名称),一刀切也不是不行,但鲁棒性很差。
所以我在做解析层的时候,没有直接写一个"解析 Markdown 表格"的函数了事,而是重新设计了一套从提示词层面就开始约束的数据管线。这套管线的核心逻辑往下看。
3. 解析层的核心决策:与其事后费力清洗,不如让模型输出 JSON
3.1 思路转变:从"人看"到"机读"
我第一版方案确实只写了解析 Markdown 表格的代码,大概逻辑是:把返回内容按行分割,找到包含 "|" 的行,过滤分隔行(就是那些 ---- 的行),然后按 | 分割字段并清理空白。这个方案能跑,但真的不稳定。为什么会不稳定?因为模型的输出偶尔会带额外解释文字,比如表格前加一句"请查收以下数据",表格后又来一句"如果需要更多信息请告诉我"。解析函数得想办法把这些噪声行剔除掉;即便剔除了,表格里一旦出现跨行单元格,或者某个字段里真的包含竖线,解析又会错位。
后来我做了一个特别关键的转向:给模型的指令里明确要求"不要输出 Markdown,只输出 JSON",同时在系统提示词里写上角色约束:"你只是一台数据生成器,禁止输出任何解释性文字。"
这样改造之后,API 返回内容的模样就从"带格式的文本"变成了干净的结构化数据:
{ "table": [ {"月份": "1月", "销售额": 120, "环比": 0.08}, {"月份": "2月", "销售额": 132, "环比": 0.10} ], "summary": {"总销售额": 252} }这个转换看似只是换了种表达,本质上是把解析任务从"自然语言理解"降维成了"字段名映射"。模型对 JSON 的结构遵循能力很强,只要提示词写清楚,它返回的 key 就不会乱变。这样后续无论写 Excel、写 CSV、写数据库,代码都是同一套逻辑,根本不用关心表格是怎么渲染的。
3.2 提示词模板:一句话告诉模型该干什么
我最终长期使用的提示词模板大概是这样的:
请根据用户要求整理数据,并严格输出 JSON。 要求: 1. 不要输出 Markdown 表格,不要使用代码围栏,不要输出多余文字。 2. 输出 JSON 格式为: {"table": [{"字段名": "值", ...}], "summary": {"字段名": "值"}} 3. 所有数值字段必须是数字类型(int 或 float),不要加千分位逗号,不要加单位。 4. 字段名使用简短中文名,确保每一行的 key 完全一致。 5. 如果数据在原文中不明确,使用 null 占位,不要自行编造。 用户需求如下: {{用户需求}}这里清单里有一条最容易被忽略:不要加千分位逗号,不要加单位。因为模型默认倾向于模仿"给人看的表格",把数字写成"120,000 元"或"65%"。你回头解析数字类型就非常费劲,"120,000" 转 float 直接报错,还得先去掉逗号;"65%" 还得先去掉百分号再除以 100。从源头掐断这些格式延伸,后面的处理省心十倍。
3.3 如果平台没有结构化输出,JSON 围栏也得处理
虽然 DeepSeek 支持 response_format 设置 json_object,但偶尔返回内容里还是会带有 ```json 围栏,这通常是因为模型在很长的输出中途切换了模式。稳妥的做法是,解析层写一个容忍函数:
import re import json def extract_json(raw: str): # 去掉可能的代码围栏 raw = raw.strip() # 匹配 ```json ... ``` 或 ``` ... ``` fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", raw) if fence_match: raw = fence_match.group(1).strip() # 直接尝试解析,失败再看是不是被包在普通文本里 try: return json.loads(raw) except json.JSONDecodeError: # 简单粗暴:从头找第一个 {,从尾部找最后一个 } start = raw.find("{") end = raw.rfind("}") + 1 if start == -1 or end == 0: raise ValueError("响应中没有找到 JSON 对象") return json.loads(raw[start:end])这个函数不完美,但覆盖率很高,能处理 90% 以上的"模型偏不听话"场景。剩余的 10% 会在下面的"坑位记录"部分展开讲。
4. 落盘导出:从内存表格到能用的 Excel / CSV
4.1 为什么我优先选 Excel 而不是 CSV
如果只是导数据,CSV 确实最轻量,pandas 一行就能搞定。但落到真实项目里,CSV 有两个让人头疼的问题:一是 CSV 没有真正的"单元格格式",数字如果带前导零(编号 "001234"),Excel 打开后会自动摘掉前导零,数据直接变脏;二是 CSV 的编码在 Excel 上需要特别注意,UTF-8 的 CSV 用 Excel 打开经常乱码,改为 UTF-8-SIG 会让部分旧工具读不了。
所以我的建议是:如果这份表格是要给人继续做分析的,直接在导出层用 openpyxl 生成 .xlsx;如果这份表格是要给程序批量消费的,CSV 也完全可以,但编码务必要注意。优先 Excel 还有一个好处:可以顺便把列宽、表头样式、数字格式一起做掉,给使用方省掉很多手工步骤。
4.2 用 openpyxl 写入工作簿
在拿到解析后的 JSON 之后,写入 Excel 的核心代码可以写成一个方法。重点不只是"写进去",还包括列宽自适应、冻结首行、数字格式设置。下面是一个我实际用的版本:
from openpyxl import Workbook from openpyxl.utils import get_column_letter from openpyxl.styles import Font, Alignment def write_json_to_excel(json_data: dict, output_path: str, sheet_name: str = "Sheet1"): rows = json_data.get("table", []) if not rows: raise ValueError("table 为空,无法导出") wb = Workbook() ws = wb.active ws.title = sheet_name # 写表头(取第一行数据的 key,保证顺序稳定) headers = list(rows[0].keys()) ws.append(headers) # 写数据行 for row in rows: ws.append([row.get(h, "") for h in headers]) # 列宽自适应 for col_idx, header in enumerate(headers, start=1): max_len = len(str(header)) for row in rows: cell_val = str(row.get(header, "")) if len(cell_val) > max_len: max_len = len(cell_val) ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 6, 50) # 表头样式 for cell in ws[1]: cell.font = Font(bold=True) cell.alignment = Alignment(horizontal="center") # 冻结首行 ws.freeze_panes = "A2" wb.save(output_path) print(f"已导出 {len(rows)} 行 -> {output_path}")这里我几次踩坑之后总结出的细节是:
提前用list(rows[0].keys())固定表头顺序,绝对不要在遍历的时候动态获取字典 key。因为 Python 3.7 以后 dict 虽然保证插入顺序,但解析后的 JSON 如果某些行的 key 顺序不一致,动态取 key 会导致同一列在不同行之间"跑偏"。固定表头之后,后面用row.get(h, "")去取,字段缺失也能补一个空值,而不是直接让程序崩掉。
4.3 数字格式与空值处理:最容易忽略的两件事
模型输出 JSON 时数字类型基本没问题,但空值很容易出幺蛾子。比如某个字段没有数据,模型可能输出 null、空字符串 "",或者干脆不写这个 key。统一处理策略是:对确实没有值的字段写空字符串,数字字段如果 null 也写空,这样使用方用 Excel 透视表时不会因为字符串 "NULL" 而误判;但对必须为 0 的字段(比如 "环比增长率"),要允许模型输出 0,不能因为要"简洁"就不给 key,否则代码里取不到值就变成空了,和 0 在语义上完全不同。这个问题在数据验证环节会放大,所以在写 Excel 的逻辑里,我额外加了一个字段类型约束表:
FIELD_TYPES = { "销售额": "number", "环比": "number", "门店": "text", "备注": "text", }写到单元格前,根据类型做一次转换:number 类型就把值转成 int 或 float,转不动就先记录日志再置空。这个约束表写死在代码里,不会从模型输出里动态推断。原因是动态推断在数据量小时偶尔灵,一旦遇到某一列全是字母数字混合,模型输出类型就飘了,容易把数值列识别成文本列。所以宁可维护一张字段表,也不做花哨的自动识别。
5. 自动化链路:让导出任务定时自己跑起来
5.1 简单的调度设计:不只导出,还要带增量
手动调脚本导出一次表格没什么难度,真正的价值在于把"每天/每周把 AI 整理好的数据落到工作簿里"这件事自动化。我做的方案分三块:任务调度、增量处理、异常预警。
任务调度选了最简单可控的 APScheduler。定时任务长这样:
from apscheduler.schedulers.blocking import BlockingScheduler from datetime import datetime def job_export_sales_report(): print(f"[{datetime.now()}] 开始导出销售报表") try: raw = ask_deepseek(REPORT_PROMPT, response_format={"type": "json_object"}) parsed = extract_json(raw) write_json_to_excel(parsed, "sales_report.xlsx", sheet_name="月度销售") print("导出完成") except Exception as e: print(f"导出失败: {e}") # 这里应该接一个 webhook 或者邮件/钉钉通知 scheduler = BlockingScheduler() scheduler.add_job(job_export_sales_report, "cron", day_of_week="mon-fri", hour=22, minute=0) scheduler.start()这个代码看着平常,但我在做增量处理时多走了一点弯路。刚开始我每次都是全量让模型输出所有数据然后再写 Excel,后来发现输出 tokens 消耗大不说,模型生成几百行表格时还会出现中途截断(max_tokens 不够),导致表格少几行,而程序没有感知到。后来改成了**"查漏补缺"策略**:第一次全量导出后,把数据集缓存一份(比如存 CSV 或者 SQLite);之后每次定时跑,都只把上次时间节点之后的新数据传给模型,输出后追加到 Excel 底部,同时更新缓存。
这样做的好处是每次 API 调用量小、返回内容短,模型几乎不会截断或出错;坏处是逻辑多了一层,要维护"哪些数据已经导出过"的状态。不过当数据来源是一个持续变动的业务表时,这层维护非常值。
5.2 失败重试与幂等保护:别让脚本半夜白跑
自动化脚本最怕的就是半夜跑挂了,第二天早上你才发现表没出来。我在这个环节加了三重保障:
第一,调用 API 失败自动重试,最多三次,用指数退避。这招对临时网络抖动特别管用。
import time def ask_deepseek_with_retry(prompt, retries=3, base_delay=2): for attempt in range(retries): try: return ask_deepseek(prompt, response_format={"type": "json_object"}) except Exception as e: if attempt == retries - 1: raise delay = base_delay * (2 ** attempt) print(f"API 调用失败,{delay}s 后重试:{e}") time.sleep(delay)第二,写 Excel 前先把内容备份到临时文件,等完整写入并校验通过后,再替换正式文件。不然如果写一半断电,正式文件会损坏,之前的版本也没了。用 Python 的话可以一条os.replace()搞定,先写临时文件再替换,这是从写数据库事务那里借来的思路。
第三,生成后的校验。我在写入后重新用 openpyxl 打开文件,对比行数和源 JSON 的行数是否一致。如果不一致就报错。这个校验逻辑虽然简单,但能拦住 90% 的"看着跑完了其实丢数据"的场景。
from openpyxl import load_workbook def validate_export(excel_path, expected_rows): wb = load_workbook(excel_path) ws = wb.active real_rows = ws.max_row - 1 # 去掉表头 assert real_rows == expected_rows, f"行数不匹配:期望 {expected_rows},实际 {real_rows}" print(f"校验通过:{real_rows} 行")5.3 日志和可观测性:自动化最容易被忽视的一环
自动化脚本跑久了,你一定会遇到"某天跑出来了但数据不对"的情况。没有日志,排查难如登天。我现在的做法是定义了一个简单的结构化日志,每次任务记录:请求开始时间、消耗 tokens、返回字符数、解析是否成功、写入行数、校验结果。用 CSV 或 SQLite 存起来都行,关键是要把这些写入一条记录里,方便事后对账。
这里特别推荐记录API 返回的 token 消耗。如果某一天数据量没变,但 token 消耗突然暴涨,大概率是模型开始输出冗长的解释文字了——这是提示词失效的前兆。提前发现才能提前修,不然等数据出了问题再翻日志,会很被动。
6. 实测踩坑记录:解析错位、格式溢出和并发限流
6.1 模型偶尔多输出一个"行":解析错位的隐形炸弹
我在测试时遇到过一次极其隐蔽的错误:模型返回的 JSON 里,有一行的字段 key 多了个空格,比如{"门店": "华东", " 销售额": 123}。JSON 解析不会报错,但到了写入 Excel 时,这行数据的销售额取不到值(因为 key 是" 销售额"而不是"销售额"),于是这一列就变成空值。整张表 99% 的内容都是对的,只有那一格是空的,肉眼还很难发现。
刚开始我还以为是模型随机丢失字段,后来打好日志才发现 key 前面多了个空格。把这层清洗逻辑加进 extract_json 之后,问题就稳定消除:
def clean_keys(obj): if isinstance(obj, dict): return {k.strip(): clean_keys(v) for k, v in obj.items()} if isinstance(obj, list): return [clean_keys(item) for item in obj] return obj这个坑提醒我一件事:模型输出的 JSON 在语法上不一定"规范",即便能 parse,里面的字符串也可能包含多余空白、全角字符等情况。所有取自模型输出的 key 和 value,在进入业务逻辑前至少要做一次 strip 处理。
6.2 遇到过长内容被截断:表格被腰斩但程序不报错
大表格导出时,API response 的 max_tokens 限制会导致返回被截断。截断后的 JSON 自然是无法解析的,extract_json 会抛异常,这反而不是最可怕的。最可怕的是:提示词让模型输出 JSON,结果模型在输出完毕前刚好用完了 token,截断位置恰好在 JSON 尾部闭合大括号之前,但前面的内容都是完整的——这种情况下 JSON 解析会失败,但如果截断时候恰好碰上了某个能形成合法前缀的节点,或者返回内容里缺失的部分不影响整体 parse,程序就可能在无感知的情况下写出了一部分数据。我的应对方案见第 5.2 节的校验逻辑,同时自己在提示词里明确标注:尽量精简字段名,不要输出冗余解释,减少 token 占用。更重要的一点是:对自定义的导出脚本,如果表格行数可能超过 100 行,直接把表格拆成多次请求处理,单次调用只处理一部分数据,用多次结果合并成最终表格,比祈祷一次请求输出所有内容靠谱得多。
6.3 并发限流:自动化脚本跑得好好的,突然 429
当脚本进入定时任务阶段,每天都会固定打 API,一旦某段时间多个任务叠加(比如几个报表刚好同一分钟触发),就很容易撞上限流。主要表现是 HTTP 429 或者连接超时。解决办法不复杂:把任务触发时间打散,让各任务错峰执行;同时在代码层做指数退避重试。
另外一个特别容易被忽略的点是:DeepSeek 的 API 在单账号上有并发限制。日志里要专门记录每次调用的耗时,如果耗时开始不稳定地上升,往往说明已经接近限流阈值了。此时应该降低调用频率,而不是继续加大并发。我在坑里撞过之后,现在默认做法是:所有批量任务强制串行化,加入一个全局队列锁,宁可慢一点,不求快。
6.4 一个特殊问题:模型把百分比符号保留导致数字转换失败
我在提示词里要求输出数字类型,但模型偶尔还是会把"环比增长 8%"写成"环比": "8%"。这会让 Excel 里的数字列变成文本,无法参与计算。我加了一个兜底转换逻辑:
def to_number(value): if isinstance(value, (int, float)): return value if isinstance(value, str): s = value.replace(",", "").replace("%", "").strip() try: return float(s) except ValueError: return None return None同时把这个兜底逻辑放进字段类型约束表里,遇到 number 类型字段就用它转。代码上多了一个分支,但确实挽救了第二次模型抽风造成的脏数据。我在日志里也单独标记了所有发生过类型转换的位置,方便后续检查是不是该优化提示词而不是依赖兜底逻辑。
最后关于这套方案,我的几个个人判断
整套链路跑下来,我最大的感受是:AI 导表的难点不在"导",而在"约束"。从提示词写清楚输出 schema,到 API 参数里固定低 temperature,再到解析层的空白清理和类型兜底,核心始终是把"模型愿意输出什么"和"程序需要什么"这两件事对齐。模型能力再强,也不能把宝全押在它身上——所有关键节点都要有一层校验和容错,像是 Excel 写出后的二次打开,像是 token 消耗的日志监控,又像是失败后的自动重试。这些琐碎但必要的东西,才是自动化链路真正稳下来的基础。
在项目里实际跑了几个月后,我还发现一个省事的小技巧:把已经验证过的提示词模板和字段定义表固化成配置,用 YAML 单独维护。以后模型版本升级或者字段变化时,只动配置不动代码,就能快速迭代,不用每次重新走一遍全链路流程。这个方法我从销售报表扩到了周报汇总、库存核对、竞品数据整理等场景,套路完全一致,搬过去改个配置就能用。
有需要细化的地方(比如多表合并、异常告警接入群机器人、数据库直接落表的写法),欢迎在评论区留言,我看到后会继续补充实测细节。