記賬工具:從自然語(yǔ)言到結(jié)構(gòu)化賬目)
簡(jiǎn)介這是一份面向個(gè)人開(kāi)發(fā)者與財(cái)務(wù)自動(dòng)化愛(ài)好者的智能記賬工具源碼包基于 OpenAI 接口與 Python 實(shí)現(xiàn)用于將銀行流水、發(fā)票、賬單等非結(jié)構(gòu)化財(cái)務(wù)文檔自動(dòng)解析為可用的記賬數(shù)據(jù)適合具備一定 Python 基礎(chǔ)、希望實(shí)踐 AI 財(cái)務(wù)場(chǎng)景的讀者。壓縮包共 51 個(gè)文件約 37KB以 36 個(gè) py 腳本為核心輔以 yaml、yml 配置定義任務(wù)與賬單流程另有 json、ini、makefile、pylintrc 等工程化文件整體結(jié)構(gòu)輕量、便于二次開(kāi)發(fā)。目錄中可見(jiàn) prompts、routers、models、utils、tests 等模塊分別承擔(dān)提示詞管理、接口路由、數(shù)據(jù)模型、日志與錯(cuò)誤處理、單元測(cè)試等職責(zé)并預(yù)留 notion 集成入口方便對(duì)接外部記賬服務(wù)。目前已有 238 人學(xué)習(xí)下載。通過(guò)該資源可掌握從數(shù)據(jù)獲取、預(yù)處理、信息抽取到結(jié)構(gòu)化落庫(kù)與報(bào)告可視化的完整鏈路理解 OpenAI 自然語(yǔ)言理解在財(cái)務(wù)場(chǎng)景中的落地方式并借鑒其工程組織與測(cè)試思路。1. 自動(dòng)記賬這件事難的不是記賬而是「記」很多人第一次聽(tīng)到「基于 OpenAI 實(shí)現(xiàn)的自動(dòng)記賬工具」腦子里浮現(xiàn)的是又一個(gè)記賬 App。但真正動(dòng)手做過(guò)的人會(huì)告訴你記賬類工具最勸退的環(huán)節(jié)從來(lái)不是賬本本身而是「記」這個(gè)動(dòng)作——手動(dòng)選分類、填金額、挑賬戶三步下來(lái)十幾秒堅(jiān)持不過(guò)兩周。我見(jiàn)過(guò)太多人下載了記賬軟件三天熱度之后賬本停在第一頁(yè)。這個(gè)方案要解決的就是這個(gè)斷點(diǎn)你只需要用自然語(yǔ)言說(shuō)一句「昨天打車(chē) 38午飯跟同事 AA 付了 65」剩下的金額提取、分類判斷、賬戶歸屬、時(shí)間解析全部交給模型和一段 Python 邏輯去完成。它適合兩類人一類是想自己搭一套私人記賬流水、又不想被 App 綁架的開(kāi)發(fā)者另一類是手里已經(jīng)有一堆微信/支付寶導(dǎo)出的 CSV想批量清洗成結(jié)構(gòu)化賬目的數(shù)據(jù)黨。核心鏈路其實(shí)很短——文本進(jìn)、結(jié)構(gòu)化 JSON 出、落庫(kù)、對(duì)賬難的是讓模型穩(wěn)定地吐出你要的字段。2. 從一句話到一條賬目解析鏈路怎么搭2.1 為什么用「函數(shù)調(diào)用」而不是讓模型直接寫(xiě) SQL新手最容易踩的坑是直接給模型一句「把這句話轉(zhuǎn)成 INSERT 語(yǔ)句」??雌饋?lái)省事實(shí)際上你會(huì)遇到三個(gè)問(wèn)題模型偶爾會(huì)編造不存在的字段名、金額單位時(shí)有時(shí)無(wú)、日期格式每次都不一樣。更麻煩的是一旦模型輸出被拼進(jìn) SQL注入風(fēng)險(xiǎn)就跟著來(lái)了。常見(jiàn)做法是用 OpenAI 的 function calling現(xiàn)在叫 tool calling能力把「記一筆賬」定義成一個(gè)帶嚴(yán)格 schema 的函數(shù)讓模型只負(fù)責(zé)填參數(shù)不負(fù)責(zé)拼語(yǔ)句。這樣模型輸出天然是 JSON字段類型由 schema 約束落庫(kù)那一步由你自己的代碼控制。我一般會(huì)把 schema 寫(xiě)成這樣# tools.py # 定義記賬工具的參數(shù) schema模型只能按這個(gè)結(jié)構(gòu)填 RECORD_TOOL { type: function, function: { name: record_transaction, description: 記錄一筆個(gè)人收支流水, parameters: { type: object, properties: { amount: { type: number, description: 金額正數(shù)表示收入負(fù)數(shù)表示支出單位元 }, category: { type: string, enum: [餐飲, 交通, 購(gòu)物, 居住, 醫(yī)療, 娛樂(lè), 工資, 理財(cái), 其他] }, account: { type: string, description: 支付賬戶如 微信/支付寶/銀行卡/現(xiàn)金 }, occurred_at: { type: string, description: 發(fā)生時(shí)間ISO8601 格式缺省用當(dāng)前時(shí)間 }, note: { type: string, description: 原始描述或補(bǔ)充備注 } }, required: [amount, category, account] } } }這段 schema 的關(guān)鍵在enum和required。enum把分類鎖死在一個(gè)閉集里模型沒(méi)法自由發(fā)揮造出「吃喝玩樂(lè)」這種新分類required保證金額、分類、賬戶三個(gè)字段一定存在缺了模型會(huì)被強(qiáng)制重試。amount用正負(fù)號(hào)區(qū)分收支比單獨(dú)加一個(gè)type字段更省事后面統(tǒng)計(jì)時(shí)直接sum就行。2.2 調(diào)用模型并拿到結(jié)構(gòu)化結(jié)果有了 schema調(diào)用邏輯就變得很直白。下面這段是核心解析函數(shù)注意tool_choice這個(gè)參數(shù)——它強(qiáng)制模型必須調(diào)用這個(gè)工具而不是跟你閑聊。# parser.py import json from openai import OpenAI from tools import RECORD_TOOL client OpenAI() # 從環(huán)境變量讀取 API Key SYSTEM_PROMPT 你是一個(gè)記賬助手。用戶會(huì)用口語(yǔ)描述一筆收支 你需要調(diào)用 record_transaction 工具提取字段。 規(guī)則 1. 支出金額為負(fù)數(shù)收入為正數(shù) 2. 無(wú)法判斷賬戶時(shí)默認(rèn)填「微信」 3. 相對(duì)時(shí)間昨天、上周五請(qǐng)換算成具體日期 4. 不要向用戶追問(wèn)直接給出最合理的判斷。 def parse_text(user_text: str) - dict: resp client.chat.completions.create( modelgpt-4o-mini, # 解析任務(wù)用輕量模型足夠 messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_text}, ], tools[RECORD_TOOL], tool_choice{type: function, function: {name: record_transaction}}, temperature0, # 解析任務(wù)要確定性溫度歸零 ) call resp.choices[0].message.tool_calls[0] return json.loads(call.function.arguments)邏輯說(shuō)明tool_choice指定具體函數(shù)名模型沒(méi)有「不調(diào)用」的選項(xiàng)這比在 prompt 里寫(xiě)「請(qǐng)務(wù)必輸出 JSON」可靠得多。temperature0是為了讓同樣的輸入盡量得到同樣的輸出記賬這種場(chǎng)景不需要?jiǎng)?chuàng)造性。gpt-4o-mini這類小模型處理字段抽取完全夠用成本比大模型低一個(gè)數(shù)量級(jí)批量清洗 CSV 時(shí)這個(gè)差距會(huì)被放大到很可觀。參數(shù)說(shuō)明model換成更強(qiáng)的模型不會(huì)讓字段準(zhǔn)確率顯著提升反而拖慢速度除非你的輸入里夾雜大量方言和隱喻。temperature不要設(shè)成 0.7 以上否則同一句話兩次解析可能給出不同分類。2.3 落庫(kù)SQLite 起步別一上來(lái)就上 PostgreSQL個(gè)人記賬的數(shù)據(jù)量一年撐死幾千條SQLite 完全夠用而且零運(yùn)維。表結(jié)構(gòu)建議把原始文本也存下來(lái)方便日后回溯模型判斷對(duì)不對(duì)。# storage.py import sqlite3 from datetime import datetime def init_db(pathledger.db): conn sqlite3.connect(path) conn.execute( CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL, category TEXT NOT NULL, account TEXT NOT NULL, occurred_at TEXT NOT NULL, note TEXT, raw_text TEXT, -- 保留原始輸入便于復(fù)盤(pán) created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() return conn def insert_tx(conn, tx: dict, raw: str): conn.execute( INSERT INTO transactions (amount, category, account, occurred_at, note, raw_text) VALUES (?, ?, ?, ?, ?, ?), (tx[amount], tx[category], tx[account], tx.get(occurred_at) or datetime.now().isoformat(), tx.get(note, ), raw) ) conn.commit()raw_text這一列是我強(qiáng)烈建議加的。模型分類偶爾會(huì)翻車(chē)比如把「買(mǎi)藥」歸到「購(gòu)物」事后你只看到一條錯(cuò)誤分類根本想不起來(lái)當(dāng)時(shí)說(shuō)了什么。留著原文排查時(shí)一眼就能定位是模型的問(wèn)題還是你表述的問(wèn)題。3. 批量清洗歷史賬單CSV 導(dǎo)入的完整流程3.1 先看清你的 CSV 長(zhǎng)什么樣從支付平臺(tái)導(dǎo)出的賬單格式五花八門(mén)。常見(jiàn)的有「交易時(shí)間 / 交易類型 / 交易對(duì)方 / 商品說(shuō)明 / 收/支 / 金額」這種列結(jié)構(gòu)也有把金額和收支方向揉在一列的。動(dòng)手寫(xiě)代碼前先花五分鐘用 pandas 看一眼import pandas as pd df pd.read_csv(bill.csv, encodingutf-8) print(df.columns.tolist()) print(df.head(3)) print(df[收/支].value_counts())這一步能幫你確認(rèn)三件事編碼是不是 UTF-8很多導(dǎo)出是 GBK讀進(jìn)來(lái)會(huì)亂碼、金額列有沒(méi)有帶「¥」符號(hào)、收支方向是單獨(dú)一列還是靠正負(fù)號(hào)。我踩過(guò)的坑是直接假設(shè)金額是數(shù)字結(jié)果pd.read_csv把它讀成字符串后面求和全是拼接。3.2 逐行喂給模型但要控制并發(fā)和成本批量場(chǎng)景下逐行調(diào)用模型是最直觀的做法但幾千行串行跑會(huì)等到天荒地老。用線程池并發(fā)同時(shí)給個(gè)上限避免觸發(fā)速率限制。from concurrent.futures import ThreadPoolExecutor from parser import parse_text from storage import init_db, insert_tx import pandas as pd def clean_amount(v): # 去掉貨幣符號(hào)和千分位逗號(hào)轉(zhuǎn)成 float return float(str(v).replace(¥, ).replace(,, ).strip()) def process_row(row): text f{row[交易時(shí)間]} {row[交易對(duì)方]} {row[商品說(shuō)明]} {row[金額]} try: tx parse_text(text) return tx, text except Exception as e: return None, text # 失敗的行先跳過(guò)后面單獨(dú)處理 def batch_import(csv_path, max_workers5): df pd.read_csv(csv_path, encodingutf-8) conn init_db() with ThreadPoolExecutor(max_workersmax_workers) as pool: for tx, raw in pool.map(process_row, df.to_dict(records)): if tx: insert_tx(conn, tx, raw) conn.close()max_workers5是個(gè)保守值實(shí)測(cè)再往上加容易撞到每分鐘請(qǐng)求數(shù)限制反而因?yàn)橹卦囃下w。失敗的行不要直接丟收集起來(lái)最后統(tǒng)一重跑往往是因?yàn)槟骋恍形谋咎貏e長(zhǎng)或者含特殊字符。3.3 用規(guī)則兜底別讓模型處理它不擅長(zhǎng)的模型擅長(zhǎng)理解「星巴克」是餐飲但不擅長(zhǎng)做精確的算術(shù)和格式轉(zhuǎn)換。金額清洗、日期標(biāo)準(zhǔn)化這類確定性工作用代碼做比用模型做又快又準(zhǔn)。我的分工原則是語(yǔ)義判斷交給模型格式規(guī)整交給代碼。比如日期模型返回的可能是「2024-03-15」也可能是「3月15日」落庫(kù)前統(tǒng)一用dateutil解析一遍from dateutil import parser as date_parser def normalize_date(s): try: return date_parser.parse(s).isoformat() except (ValueError, TypeError): return None # 解析不了就交給上層用當(dāng)前時(shí)間兜底4. 避坑與排查那些讓賬本對(duì)不上的細(xì)節(jié)4.1 金額正負(fù)號(hào)反了月底統(tǒng)計(jì)全錯(cuò)現(xiàn)象月底一看支出是負(fù)數(shù)收入也是負(fù)數(shù)總支出算出來(lái)是個(gè)正數(shù)。原因多半是模型把「支出」也返回了正數(shù)而你的統(tǒng)計(jì)邏輯假設(shè)支出為負(fù)。解決在落庫(kù)前加一道校驗(yàn)如果category屬于支出類且amount 0強(qiáng)制取反反之收入類強(qiáng)制取正。別指望 prompt 里寫(xiě)一句「支出為負(fù)」模型就永遠(yuǎn)遵守加代碼校驗(yàn)才是后悔藥。4.2 同一筆賬被記了兩次現(xiàn)象批量導(dǎo)入時(shí)發(fā)現(xiàn)某些行重復(fù)。原因通常是并發(fā)重試——某次請(qǐng)求超時(shí)了你的代碼重試但第一次其實(shí)已經(jīng)成功落庫(kù)。解決給transactions表加一個(gè)基于raw_text occurred_at的唯一索引插入時(shí)用INSERT OR IGNORE。這樣即使重試也不會(huì)產(chǎn)生重復(fù)行。4.3 分類總是往「其他」里塞現(xiàn)象大量賬目分類是「其他」模型像是偷懶。原因有兩個(gè)一是你的enum里分類太粗模型找不到合適的就往兜底項(xiàng)放二是 prompt 里沒(méi)給分類的定義。解決在 system prompt 里給每個(gè)分類配一句說(shuō)明比如「餐飲指正餐、外賣(mài)、咖啡奶茶購(gòu)物指實(shí)物商品」。實(shí)測(cè)加了定義之后「其他」的占比能從三成降到一成以內(nèi)。4.4 中文編碼把 CSV 讀成亂碼現(xiàn)象pd.read_csv之后列名是??o¤?這種。原因文件是 GBK 編碼你用 UTF-8 讀。解決先試encodingutf-8報(bào)UnicodeDecodeError就換encodinggbk。更穩(wěn)的做法是用chardet探測(cè)編碼但個(gè)人項(xiàng)目里手動(dòng)試兩次就夠了。4.5 模型偶爾返回空參數(shù)現(xiàn)象json.loads報(bào)錯(cuò)或者amount字段是None。原因輸入文本太短或太模糊比如「花了點(diǎn)錢(qián)」模型無(wú)法確定金額。解決在parse_text里加一層校驗(yàn)amount缺失時(shí)拋出明確異常讓上層決定是丟棄還是標(biāo)記為待補(bǔ)充。不要讓它靜默寫(xiě)入一條金額為 0 的記錄那比報(bào)錯(cuò)更危險(xiǎn)。5. 讓賬本自己會(huì)說(shuō)話從流水到月度洞察賬目落庫(kù)只是起點(diǎn)真正有價(jià)值的是讓模型基于結(jié)構(gòu)化數(shù)據(jù)做二次分析。這一步和前面的解析是反過(guò)來(lái)的——前面是「文本轉(zhuǎn)結(jié)構(gòu)」這里是「結(jié)構(gòu)轉(zhuǎn)洞察」。我一般會(huì)寫(xiě)一個(gè)查詢函數(shù)把某個(gè)月的流水拉出來(lái)拼成一段緊湊的文本再讓模型生成一段人話總結(jié)。def monthly_insight(conn, year_month2024-03): rows conn.execute( SELECT category, SUM(amount) AS total, COUNT(*) AS cnt FROM transactions WHERE occurred_at LIKE ? GROUP BY category ORDER BY total ASC, (f{year_month}%,) ).fetchall() summary \n.join( f{r[0]}: 合計(jì) {r[1]:.2f} 元, 共 {r[2]} 筆 for r in rows ) prompt f以下是我 {year_month} 的支出分類匯總\n{summary}\n \ 請(qǐng)用三句話指出最值得注意的一點(diǎn)并給一條可執(zhí)行的省錢(qián)建議。 resp client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0.5, ) return resp.choices[0].message.content這里temperature可以稍微調(diào)高到 0.5讓總結(jié)讀起來(lái)不那么機(jī)械。注意查詢里ORDER BY total ASC因?yàn)橹С鍪秦?fù)數(shù)升序排列意味著花得最多的排在最前面模型一眼就能看到大頭在哪。進(jìn)階一點(diǎn)的做法是把連續(xù)幾個(gè)月的分類匯總都喂進(jìn)去讓模型找趨勢(shì)。比如「餐飲支出連續(xù)三個(gè)月上升」這種結(jié)論靠人翻賬本很難發(fā)現(xiàn)但模型對(duì)比幾組數(shù)字就能指出來(lái)。參數(shù)上要注意的是喂給模型的匯總文本別太長(zhǎng)超過(guò)幾百行反而會(huì)稀釋重點(diǎn)按月聚合到分類級(jí)別就夠了。還有一個(gè)我常用的技巧讓模型給每筆大額支出打一個(gè)「必要性」標(biāo)簽分「必要 / 可選 / 沖動(dòng)」三檔存回?cái)?shù)據(jù)庫(kù)。月底一統(tǒng)計(jì)沖動(dòng)消費(fèi)占比一目了然。這個(gè)標(biāo)簽同樣用 tool calling 實(shí)現(xiàn)schema 里加一個(gè)enum字段即可和前面的記賬工具是同一套模式。我自己用這套東西跑了小半年最大的教訓(xùn)是別追求一次把分類體系設(shè)計(jì)完美。一開(kāi)始我列了二十多個(gè)分類結(jié)果模型經(jīng)常在相近分類之間搖擺后來(lái)砍到九個(gè)準(zhǔn)確率反而上去了。分類是給人看的夠用就行剩下的細(xì)節(jié)交給note字段。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取