从零开始编写自己的AI账单Agent

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解释如下:

简单来说,ReActReason(推理) + Act(操作) 的缩写。

它是大模型(LLM)的一种工作模式,让模型在执行复杂任务时,不仅会"想",还会"做"。

核心原理

ReAct 模仿人类解决问题的思维过程,通过一个循环的反馈链条来实现:

  1. Thought(思考): 模型分析当前任务,思考下一步该做什么。
  2. Action(行动): 模型调用外部工具(如搜索网页、查询数据库、计算器)。
  3. 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模式本身也很有意义,通过提示词工程,让大模型一步步按照自己的想法执行,就像培养一个听话但不太灵光的孩子,有种"模拟养成"的快乐。

相关推荐
Hilaku2 小时前
我是如何用一行 JS 代码,让你的浏览器内存瞬间崩溃的?
前端·javascript·node.js
努力犯错玩AI2 小时前
如何在ComfyUI中使用Qwen-Image-Layered GGUF:完整安装和使用指南
前端·人工智能
石臻臻的杂货铺2 小时前
参数仅 1/30 却追平闭源巨头?MiroThinker 1.5 开源实测:普通人也能拥有的“顶级情报官”
开源·ai编程
进阶的鱼2 小时前
一文助你了解Langchain
python·langchain·agent
Lefan2 小时前
在浏览器中运行大模型:基于 WebGPU 的本地 LLM 应用深度解析
前端
五仁火烧2 小时前
npm run build命令详解
前端·vue.js·npm·node.js
曦和2 小时前
从0到1搭建AI应用:GPT-5.2接入完整实战(2026最新)
ai编程
贺今宵2 小时前
electron-vue无网络环境,读取本地图片/文件展示在页面vue中protocol
前端·javascript·electron
IT_陈寒2 小时前
SpringBoot 3.x实战:5个高效开发技巧让我减少了40%重复代码
前端·人工智能·后端