
AI时代,很难想象记账App还没有用AI帮自己分析账单的功能
起因是有小伙伴问,能不能给Cent加上AI记账分析的功能,一开始我觉得这当然没问题了,实在是太简单了,不就是加一个聊天框,然后预设一些提示词发给AI,然后就成了嘛,于是我直接开写。
当我把整体流程吭哧吭哧写完了,点击发送按钮一看,AI返回了一个大大的问号:
好的,请提供您的账单数据,我将会尽力为你进行分析
我才想起忘了把账单数据也一起发过去,这好办,我直接在systemPrompt里开写:
下面是用户的账单数据:
这时我马上意识到了一个问题,那就是如果真的把账单数据一股脑传给AI,假设用户一天产生5条账单,一个月就是150条,一年就是1000+条数据,这么多数据传过去,先不说token消耗数直接爆炸,Transformer注意力机制下,这么多数据AI能否分析成功也是个未知数,保不齐就开始胡诌一个平均支出,或者把开始的数据忘得一干二净了。
直接把账单数据传给AI显然是不合理的,于是我想到了目前最热门的Curosr、Manus等AI工具,它们是如何解决上下文问题的呢?于是我开始问Gemini,找到了这样一个关键词:ReAct。
ReAct
此ReAct当然不是前端用的彼React,而是一种全新的AI上下文处理架构,Gemini解释如下:
简单来说,ReAct 是 Reason(推理) + Act(操作) 的缩写。
它是大模型(LLM)的一种工作模式,让模型在执行复杂任务时,不仅会"想",还会"做"。
核心原理
ReAct 模仿人类解决问题的思维过程,通过一个循环的反馈链条来实现:
- Thought(思考): 模型分析当前任务,思考下一步该做什么。
- Action(行动): 模型调用外部工具(如搜索网页、查询数据库、计算器)。
- Observation(观察): 模型阅读工具返回的结果,并据此修正或继续下一步推理。
为什么需要它
- 弥补缺陷: LLM 有时会"一本正经胡说八道",且无法获取实时信息。
- 逻辑闭环: 通过"观察"外部事实,模型能自我纠错,从而更精准地达成目标。
一句话总结: ReAct 是给大模型装上了"大脑(逻辑)"和"双手(工具)",让它通过边想边做来解决问题。
原理很简单,现在各家AI厂商都在用的MCP、Skills模式,其实都是为了更好地让大模型遵循一定的步骤,来依分步完成复杂任务,而为了实现ReAct,将不确定的大模型回答转换为确定的数据收集工作,就需要让大模型能够调用确定的"功能",将这些"功能"的输出提供给大模型,而不是一股脑把所有数据丢给它,这样就能最大化减少token的消耗,以及避免幻觉,同时也能带来更好的执行结果。
在Cent中,账单数据是十分确定的,统计和分析功能也是现成的,剩下的问题就是该如何让大模型了解和使用这些现成的工具了。那么该如何实现简单的ReAct模式呢?
Function Call
Gemini给的方式非常简单,让大模型遵循function call格式的输出,然后在自己的解析代码中实现调用就可以了。Function call是Open AI很早前提出的一套用于规范大模型输出的提示词工程,旨在让大模型返回标准化的json结构,然后通过解析json来获取确定格式的输出,方便调用标准化的函数,其工作原理也很简单,就是先为自己的工具函数定义一套标准的JSON描述,例如函数名称,函数参数,每个参数的作用,返回值的定义等,相当于给函数一个大模型看得懂的注释,然后再要求大模型在需要调用工具获取信息的时候,按照给定的json格式输出,这样通过解析大模型的输出,就可以知道大模型要如何调用这些函数,再将函数执行结果重新返回给大模型,就实现了大模型"调用"工具的结果。
Gemini很快就给出了一套完善的function call的提示词,以及对应的解析方式,看起来很完美,但是当我把这套提示词发送给大模型时,又出岔子了,大模型根本就没按我给的格式来,而是自顾自地把Open AI那一套python格式的function call调用方式返回了,理所当然解析失败。
提示词改进
我用的大模型是智谱GLM flash系列,它的"智力"显然比不上目前的闭源大模型,目前的提示词约束力显然不太够,而且很明显它被训练过程的Open AI版本的function call协议污染了,一直按照Open AI的格式来,而对我的"简化版"协议置若罔闻。我只能完全放弃提到function call,重新设计一套更简洁的"协议",即最终版:
markdown
# 强制输出格式
所有回答必须包含在对应的 XML 标签中,格式如下:
<TITLE>此处为简短标题</TITLE>
<Thought>
此处记录你的思考过程。
1. 分析用户意图。
2. 决策:是宏观统计(analyze)还是微观查询(query)?
3. 确定所需的参数。
</Thought>
<Tool>
function=工具名称
参数名1=参数值1
参数名2=参数值2
</Tool>
<Answer>
最终的输出
</Answer>
事实证明,xml版的协议对于小体量模型来说,约束力更强,也更不容易生成出错,并且解析代码也可以更加简洁,也更宽容,之前生成json的时候很容易出现匹配时多匹配了个引号或者大括号导致解析失败,换成xml之后只用正则就可以精准匹配想要的内容,而且拓展性也更强了。
typescript
/**
* 健壮的 XML 标签解析器 (Loose Parsing)
* 即使缺少闭合标签或格式稍有偏差也能尝试提取
*/
/**
* 增强后的解析器:支持 Answer 标签的提取与清洗
*/
function parseStandardResponse(response: string): ParsedResponse {
const extractTag = (tag: string, input: string) => {
// 匹配 <Tag>内容</Tag> 或 <Tag>内容 (支持未闭合情况)
const regex = new RegExp(`<${tag}>([\s\S]*?)(?:<\/${tag}>|$)`, "i");
const match = input.match(regex);
return match ? match[1].trim() : undefined;
};
const title = extractTag("TITLE", response);
const thought = extractTag("Thought", response);
const toolRaw = extractTag("Tool", response);
const answerTagContent = extractTag("Answer", response); // 新增:提取 Answer 标签内容
let toolCall = null;
if (toolRaw) {
toolCall = parseToolContent(toolRaw);
}
// 清洗逻辑:移除所有 XML 块,包括 Answer
const cleanedRemainder = response
.replace(/<TITLE>[\s\S]*?(?:</TITLE>|$)/gi, "")
.replace(/<Thought>[\s\S]*?(?:</Thought>|$)/gi, "")
.replace(/<Tool>[\s\S]*?(?:</Tool>|$)/gi, "")
.replace(/<Answer>[\s\S]*?(?:</Answer>|$)/gi, "") // 新增:移除 Answer 标记及其内部内容
.trim();
// 最终显示内容的优先级:
// 1. 如果有 <Answer> 标签,优先使用标签内的内容
// 2. 如果没有 <Answer> 标签,则使用移除所有标签后的剩余文本
const content = answerTagContent || cleanedRemainder;
return {
title,
thought,
toolCall,
content,
raw: response,
};
}
xml版"function call"解析器
函数优化
解决了大模型如何调用工具的问题,接下来就是如何设计工具了,对于记账App来说,即使过滤出了一段时间的账单,数量也可能还是太多了,直接返回过滤后的结果账单也没有比一开始的实现好上多少。因此,设计一个具有统计功能的工具是必要的,它应该返回全局性的统计数据,而非单纯的账单过滤,只有当用户明确需要查询具体账单时,才让大模型去调用对应的搜索工具。为此,我让Gemini设计了如下几个函数:
markdown
# 可用工具
## 1. analyze_bills - 账单统计与分析 (优先使用)
**强烈建议**:当用户询问"总额"、"占比"、"哪类花钱最多"、"趋势"、"概况"时,必须使用此工具。它能返回高密度的统计结果,避免数据过载。
- **参数**:
- function: "analyze_bills"
- startTime: YYYY-MM-DD
- endTime: YYYY-MM-DD
- categoryNames: 分类名(逗号分隔,支持模糊匹配)
- tagNames: 标签名(逗号分隔)
- keyword: 备注关键词
- minAmount / maxAmount: 金额范围(数字)
- billType: "income" 或 "expense"
- groupBy: 分组维度,可选值:
- "category": 按分类统计(默认,适合看消费构成)
- "tag": 按标签统计(适合看特定事件/项目)
- "day": 按日统计(适合看每日变化)
- "month": 按月统计(适合看月度趋势)
- limit: 返回前几项(数字,默认10)
- includeTrend: "true" 或 "false" (是否包含时间趋势数据,用于分析波动)
## 2. query_bills - 查询原始账单明细
**仅在以下情况使用**:用户明确询问"具体的某一笔交易"、"搜索特定备注"或"列出最近几笔账单"时。不要用于宏观统计。
- **参数**:
- function: "query_bills"
- xxx
## 3. get_account_meta - 获取账本信息
用于获取当前账本定义的分类结构和标签列表。
- **参数**:
- function: "get_account_meta"
再通过提示词引导大模型在合适的场景调用合适的函数,最终传递给大模型的token数量得到了大幅缩减,不仅守护了用户的钱包,也获得了更高的回答质量。
至此,基于智谱GLM-Flash的,能够动态进行账单分析的ReAct模式版AI助手功能正式上线Cent,我尝试了预设提示词,每个提示词都能很好地完成从函数调用到数据分析的完整链路,即使是"生成年度总结"这种需要多步推理和函数调用的问题,模型也能很好地一步步进行执行和汇总,效果让我十分满意,只是受限于模型本身的参数,生成的回答深度比起其他闭源模型还是差了点意思,不过智谱的API是我暂时能找到的唯一免费,且支持跨域的API了,还要啥自行车呢。
抛开最终效果不谈,探索Agent模式本身也很有意义,通过提示词工程,让大模型一步步按照自己的想法执行,就像培养一个听话但不太灵光的孩子,有种"模拟养成"的快乐。