搞AI智能体最容易被忽略的不是模型选型,而是全流程评估。我自己刚做智能体那会儿,模型换了好几版,Prompt也反复重写,结果一到真实业务场景就翻车:文档分类标签混乱,邮件内容看起来没问题但完全不符合对方身份。后来我把整套流程拆开重做,用LlamaAgents Builder做编排,用Promptfoo做评测,把文档分类数据和邮件撰写串在同一个智能体链路里,底层模型用GPT-5.4,才算是真正跑通了一条可复现的落地路径。这篇内容就是完整复盘整个开发过程,包含关键代码、配置和踩坑记录,适合正在做AI智能体应用,尤其是文档处理、邮件生成这类想真正落地的朋友参考。
1. 项目定位与整体架构拆解
1.1 核心场景:文档分类到邮件撰写的联动链路
在真实业务里,文档和邮件从来不是两件事。客户发来一封询价邮件,我们不仅要知道这是“咨询”,还要知道对方询的是哪款产品、大概数量、紧急程度,然后才能回复一封有针对性邮件。同样,客户发来一封投诉邮件,系统要识别出这是高优先级问题、涉及哪个订单、希望得到什么补救方案,再自动生成初审邮件的草稿,交给人工确认。
这个项目的核心就是把“文档分类”和“邮件撰写”连成一条串行的智能体流水线。智能体先读取原始文档,抽取分类、优先级、关键实体,再把这些结构化数据交给邮件智能体,生成一封符合业务场景的邮件。整个过程要解决的不只是“识别得快不快”或“邮件像不像人写的”,而是两头能否稳定衔接。
这里有个容易被忽略的点:分类不是终点。分类结果如果不被下游使用,它的价值就是零。在这个项目里,分类的标签体系要反过来从邮件写作需求设计。比如说,邮件需要称呼对方姓名,分类阶段就要提取联系人实体;邮件需要回应客诉,分类阶段就要识别投诉情绪强度和期望解决方案。所以动工第一件事不是写提示词,而是梳理下游任务到底需要哪些字段,再回头定义分类任务的输出格式。
1.2 为什么选这套技术组合
我拆过不少智能体项目,选型时容易陷入两个极端:要么全手动,写一堆调度代码,小流程也搞得像分布式系统;要么全自动,拖个界面,但等到要评估效果时发现根本没法量化。这套组合算是平衡点。
LlamaAgents Builder负责编排。它可以把分类Agent、邮件Agent定义成独立节点,节点之间靠结构化数据传递。好处是后边想再加一个摘要Agent或者审批Agent,不用重构主流程。配置走YAML,可以直接纳入版本管理,谁改了什么一目了然。
Promptfoo负责评测。它的定位很像单元测试框架,但测试对象是Prompt和模型输出。用YAML写一批固定用例,每次改动后立刻能看到哪些用例通过、哪些失败,这比肉眼测几遍可靠太多。尤其智能体链路上下协作,上游分类输出稍微变一点,下游邮件就可能跟着错,没有自动回归测试根本扛不住。
GPT-5.4负责核心推理能力。文档分类和邮件撰写都要求文本理解与生成能力,同时GPT-5.4支持JSON结构化输出,方便下游解析。我不选更重的微调方案,是因为当前业务标签体系还在快速迭代,用大模型做封闭式分类,迭代成本远低于维护一个微调模型,效果差距也没有想象中大。
1.3 智能体的数据流转设计
整个流程可以抽象成四个阶段:
- 文档预处理:清洗文本、识别文件类型、去掉页眉页脚和乱码。
- 分类推理:输出JSON结构,包含category、priority、entities、summary等字段。
- 邮件上下文组装:把分类字段映射成邮件模板需要的信息。
- 邮件生成:生成主题与正文,支持人工审核。
这条链路的重点是数据契约。两个Agent之间不直接传原始文档,而是传固定结构的JSON。比如分类Agent输出的category字段只能从预定义集合里取值,不能自由发挥,否则邮件Agent根本无法判断应该走哪条话术分支。这个契约在设计初期就要定下来,后期改字段成本很高,我试过中途把category改成label,结果一晚上都在修测试用例。
2. 环境准备与工具链搭建
2.1 环境依赖安装
我默认你已经有了Python 3.10以上的环境。项目用到的核心包比较简单:
pip install llama-agents-builder promptfoo openai python-dotenvopenai用来调用GPT-5.4接口,llama-agents-builder用来加载智能体配置并调度,promptfoo通过它的CLI执行评估。密钥统一放在.env文件里,代码里只需要load_dotenv()。
需要说明的是,llama-agents-builder不是那种很重的框架,它更偏声明式Agent运行时。你定义YAML,它负责调度。这对习惯写代码的人反而友好,因为配置能进Git,每个版本都可追溯。Promptfoo底层是Node工具,但作为命令行用就行,不需要在Python里写胶水代码。
2.2 智能体编排配置
在项目根目录建一份agents.yaml,定义两个Agent以及它们之间的依赖关系。
version: "1.0" agents: - name: doc_classifier role: classifier model: gpt-5.4 temperature: 0.1 input: document_content: string document_type: string output: category: string priority: string entities: object summary: string - name: email_writer role: writer model: gpt-5.4 temperature: 0.7 input: category: string priority: string entities: object summary: string output: subject: string body: string pipeline: - doc_classifier - email_writer两个Agent之间通过output和input的同名字段对齐。doc_classifier输出的category会作为email_writer的输入自动注入。这样写的好处是清晰,坏处是字段命名如果有变动,下游跟着断。所以字段命名一定要先定好,后期尽量少动。这里category字段的候选值也要在后续分类提示词里写死。
2.3 模型接入与推理参数
调用GPT-5.4的时候,我建议在请求参数里显式要求JSON输出。对大模型来说,不给格式约束时输出里很容易夹带解释性文字,比如“好的,分类结果是”之类的开头。一旦要求JSON,分类Agent的输出稳定性会明显提升。
分类任务我设temperature=0.1,越低越好,确保同一份文档多次分类结果基本一致。邮件撰写任务设temperature=0.7,保留一点创作空间。这个参数不是随便拍的,而是任务性质决定的:分类是判别任务,随机性越低越好;邮件是生成任务,需要一些措辞变化,否则批量场景容易显得特别模板化。
3. 文档分类数据接入与智能体实现
3.1 数据预处理与文本清洗
原始文档形态很多,有PDF、Word、扫描件。我的做法是先把它们统一转成纯文本,再用脚本处理。PDF我用文档解析库抽文本,Word转成docx后提取,这些步骤不建议放进智能体里,放在独立预处理阶段跑掉。
真正要注意的是噪声控制。PDF页眉页脚、表格碎片、乱码字符都会干扰分类。一般我会过滤长度小于2的乱码行,合并被打断的段落,去掉页码信息。预处理的目标不是让文本变得好看,而是让大模型拿到干净输入。实测里乱码对分类准确率影响很大,有一次客户投诉文档因为乱码被分到“咨询”,后续邮件话术直接带偏了,还好有人工审核兜住。
处理完的文档还需要保留基本信息,比如文件类型、文档时间、内部的订单号、金额关键词位置。这些信息会传给分类Agent作为辅助字段,帮助模型做判断。
3.2 封闭式标签体系设计
分类标签我强烈建议用封闭集合。开放标签虽然看着灵活,但邮件Agent没法基于一个未知类别做话术选择。这个项目里,我定义七个初始标签:合同、发票、投诉、咨询、催款、通知、其他。
每个标签还需要附带业务字段。比如投诉类需要priority字段,取值范围是low、medium、high,咨询类需要product字段和quantity字段。实际项目中,我通常为每个类别设计独立的输出字段,但主链路保持统一JSON结构。邮件Agent只需根据category判断走哪个分支,再读取对应可选字段。
标签命名也有讲究。比如“投诉”不要写成“客户投诉”,“咨询”不要写成“业务咨询”。太长的名称会让模型纠结,也会让输出字段值不稳定。我用两字或四字的固定词,同时给Prompt里加一句说明:如果没有完全匹配的类别,输出other,不要自行发明标签。
3.3 分类Agent的代码实现
有了上面配置,实际调用代码很简洁。核心是加载配置、调用Agent、解析JSON:
import json from llama_agents_builder import AgentRuntime from dotenv import load_dotenv load_dotenv() def classify_document(content: str, doc_type: str) -> dict: runtime = AgentRuntime.load("agents.yaml") classifier = runtime.get_agent("doc_classifier") output = classifier.run({ "document_content": content[:8000], "document_type": doc_type }) if isinstance(output, str): output = json.loads(output) return output clean_text = load_clean_text("some_document.pdf") result = classify_document(clean_text, "pdf") print(json.dumps(result, ensure_ascii=False, indent=2))输出结构类似下面这样:
{ "category": "complaint", "priority": "high", "entities": { "company": "某合作方", "contact": "A先生", "order_no": "PO-2024-0331" }, "summary": "客户反馈交付延迟3天,要求本周内确认补救方案。" }这里有个细节:传给模型的内容不要用整个PDF文档,尤其当文档很长时。我做了截断策略,优先保留开头、结尾和包含订单号、金额的行。因为很多文档的核心诉求集中在首尾,这种策略在分类任务里不会损失准确率,反而能减少模型被中间无关信息干扰的可能。
3.4 分类结果如何衔接邮件环节
分类结果落地后,不能直接丢给邮件Agent,需要先做一层字段映射。比如category=complaint且priority=high,邮件Agent要走“高优先级客诉”分支;category=invoice则走“发票确认”分支。
映射逻辑我放在代码里,不写进Prompt。因为写进Prompt会让模型每次都要做业务判断,占用上下文、增加不稳定。代码里写死的条件对于确定性规则更可靠:
def build_email_input(classification: dict) -> dict: route = { "complaint": "complaint_email", "invoice": "invoice_email", "inquiry": "inquiry_email" }.get(classification["category"], "general_email") return { "route": route, "priority": classification["priority"], "entities": classification["entities"], "summary": classification["summary"] }这一步就是所谓的“融合文档分类数据与邮件撰写”的关键位置。分类数据经过规则层之后,才作为邮件Agent输入。这样设计的好处是,哪怕模型分类对了,业务规则也能防止它走错话术分支,相当于加了一道确定性保险。
4. 邮件撰写Agent:让分类数据变成邮件内容
4.1 邮件Prompt模板设计
邮件Agent的Prompt不能只写“请写一封邮件”,这是新手常见错误。我一般分成系统指令和用户指令两层。
系统指令部分固定角色与约束:
你是某公司的商务运营助理。 根据用户提供的文档分类结果撰写邮件。 要求: 1. 邮件必须以正确的称呼开始,称呼来自entities.contact或entities.company; 2. 正文必须复述文档摘要中的核心事实,禁止编造订单号、金额、日期; 3. 语气根据route字段变化:投诉邮件要诚恳,咨询邮件要热情且信息准确; 4. 主题不超过20个字,正文不超过150字; 5. 只输出JSON,格式为{"subject": "...", "body": "..."}。用户指令部分传入结构化分类结果。这样邮件Agent不需要看原始文档,只需要看压缩后的分类信息,既省Token,又不容易被无关文本带偏。
4.2 为什么要在邮件中嵌入摘要
直接让模型看原始文档再写邮件,上下文会太长,而且模型容易漏掉真正的核心诉求。利用分类阶段生成的summary,等于提前帮模型做了一次信息压缩。实测里,带summary的邮件比直接输入原文的邮件,事实性错误率低了很多。
但summary不能原封不动复制。我要求模型重写,避免跟上游输出“撞词”。邮件正文至少要把summary里的主谓宾顺序调整一下,比如把“客户反馈交付延迟3天”改成“本次交付延误了3天,我们对此非常抱歉”。这样读起来更像自然沟通,而不是机器搬运。
另外,邮件正文里不要出现“根据文档摘要”这类话。这是模型自己脑补出来的表述,真实商务邮件里不会有人在正文里说“根据摘要”。这类口水话会让邮件一眼假,需要在Prompt里明确禁止。
4.3 邮件Agent的实现代码
调用方式与分类Agent类似,但需要确保输出是合法JSON。
def generate_email(email_input: dict) -> dict: runtime = AgentRuntime.load("agents.yaml") writer = runtime.get_agent("email_writer") output = writer.run(email_input) if isinstance(output, str): output = json.loads(output) required_keys = {"subject", "body"} missing = required_keys - output.keys() if missing: raise ValueError(f"邮件输出缺少字段: {missing}") return output email = generate_email(build_email_input(result)) print(email["subject"]) print(email["body"])到这里,一条完整链路已经跑通:文档进入后,自动输出一封可初审的邮件草稿。我通常会在邮件Agent之后再加一个人工审核节点,审核通过才能发送。这个“人审”节点必须保留,不能完全交给模型自动发信,尤其面向外部客户的场景。
4.4 邮件安全拦截图
邮件自动生成最怕三种问题:称呼错误、价格错误、情绪过激。我的做法是在输出之后加一段校验代码,把常见价格、订单号从原始文档中抽出来,与邮件正文交叉比对。如果邮件正文出现了原文没有的金额数字,直接拦截,不进入待发送队列。
这个校验不能用模型做,因为模型可能“合理推测”出错误价格。规则层用字符串匹配就能解决问题。比如文档里提取到订单号PO-2024-0331,邮件正文里出现另一个订单号,系统就要报警。这类校验虽然简单,但在真实业务里能挡住大部分低级错误。
5. 用Promptfoo搭建系统化评估
5.1 为什么要系统化评估
我一开始也是肉眼测效果,改一版Prompt就手动试几个文档,自我感觉良好。后来发现这种评估有个致命问题:不可复现。很可能我改了一版Prompt后,投诉类变好了,但咨询类被改坏了,肉眼根本发现不了。Promptfoo解决的就是这个问题。它把测试用例固化成YAML,每次跑完给出通过、失败结果,让我能准确知道改动影响了哪些类别。
这套评估逻辑很像单元测试。模型和Prompt是被测代码,测试用例代表业务预期。每次改动Prompt都要回归全部用例,不改就不跑。这么做还有个附带好处:新同事接手项目时能看到完整的用例库,知道哪些点不能改坏。
5.2 评估用例集构建
用例不能太少。我准备了至少20条真实业务文档,覆盖七类标签,每类至少两条。投诉类里再细分高优先级和低优先级。每条用例至少包含三个断言:分类是否正确、输出是否是合法JSON、邮件是否包含摘要里的关键实体。
举一个用例样例:
| 用例编号 | 输入文档内容 | 期望标签 | 期望优先级 | 邮件关键断言 |
|---|---|---|---|---|
| 001 | 发票编号INV-2024-056,金额5000元,要求尽快付款 | invoice | medium | 正文需包含发票编号 |
| 002 | 订单PO-2024-0331延迟3天,客户强烈不满 | complaint | high | 正文需道歉并给出补救时间 |
| 003 | 咨询产品A的报价,计划采购100台 | inquiry | low | 正文需提到产品A和报价引导 |
这些用例内容要脱敏,不能直接用真实客户名。我习惯用“某合作方”“A先生”代替。脱敏不影响断言逻辑,但能避免评估结果或日志泄露客户隐私。
5.3 Promptfoo配置与断言写法
Promptfoo的核心是promptfooconfig.yaml。我把分类和邮件两个Prompt分别写在独立模板文件里,然后统一配置:
prompts: - file://prompts/doc_classifier.txt - file://prompts/email_writer.txt providers: - openai:gpt-5.4 tests: - vars: document_content: "发票编号INV-2024-056,金额5000元,要求尽快付款。" document_type: "pdf" assert: - type: is-json - type: python code: "assert output['category'] == 'invoice', output" - type: python code: "assert 'INV-2024-056' in output.get('summary', ''), output" - vars: route: "complaint_email" priority: "high" summary: "客户反馈交付延迟3天,要求本周内确认补救方案。" entities: '{"company":"某合作方","contact":"A先生"}' assert: - type: python code: "data = __import__('json').loads(output)\nassert data['body'][:20], data" - type: contains value: "抱歉"这里要注意,测试输出会和原始模型输出格式强相关。最好先让分类Agent输出JSON,再用Python断言解析。如果输出出现“好的,分类结果是”这种前缀,is-json断言会直接失败,这其实是好事,因为及时捕获了格式不稳定问题。
5.4 评估结果与人工评分结合
Promptfoo跑完会生成一份报告,我能看到整体通过率和单条用例的差异。光看通过率不够,我还会把失败用例归类:是分类错、JSON格式错、还是邮件缺少关键字段。如果连续三次改动都有一类失败,说明业务逻辑本身有问题,而不是Prompt能解决的。
我还会加一层人工评分。自动断言只查“有没有”,不查“语气对不对”。所以我随机抽10条邮件,人工打分,满分为5分。低于3分的版本不进生产。这样做虽然麻烦,但能防止GPT-5.4的自动评估把“看起来通顺但很假”的邮件评高分。
6. 常见问题与排查技巧实录
6.1 上下文太长导致费用飙升
最开始我把整个文档都丢给GPT-5.4,单次调用Token数经常超过2万,跑一批评估用例费用很可观。后来我加了截断策略:文档前2000字、后2000字,再补上包含关键实体和金额的行。实测分类准确率没降,费用降了七成。
截断还有一个隐形好处:模型不容易被中间大段不相关文本干扰。很多投诉信在中间会有一段背景叙述,跟实际诉求关系不大,留着反而让分类结果摇摆。
6.2 分类结果不稳定
如果同一份文档跑多次分类结果不一致,第一个排查点是temperature。很多模型接口默认0.7,对分类任务偏高,我全部改成0.1。第二个排查点是标签命名,太含糊的标签会让模型纠结。第三个排查点是少样本示例,在Prompt末尾加两个分类示例效果很明显。
我还会在分类Prompt里明确写:“如果没有完全匹配的类别,输出other,不要自行发明标签。”否则模型容易输出标签的变体,比如把“投诉”写成“客户问题反馈”,下游规则匹配直接失效。这个坑我踩过好几次。
6.3 邮件语气忽冷忽热
投诉邮件和咨询邮件如果共用同一个Prompt但没有强约束,模型很容易把投诉写得太正式而缺乏诚意,或者把咨询写得过于推销。我的做法是给不同route各配一段风格示例。投诉示例是“先道歉、再确认问题、最后给补救时间”;咨询示例是“先感谢、再给产品亮点、最后引导下一步沟通”。
Promptfoo在这时特别有用。我给每个route各配两条测试用例,专门断言邮件里是否出现道歉词,或者是否包含产品名称。如果用例失败,我能立刻定位是哪个route的Prompt出了问题。
6.4 评估通过但人工感受不一致
有几次Promptfoo报告全部通过,但我读邮件觉得特别别扭。原因是自动断言只检查了关键词,比如“抱歉”出现就算通过,但邮件整体逻辑不通。所以我后来引入了混合评审:自动断言筛掉硬伤,人工抽查语感。如果自动全过但人工分数持续偏低,我会调整Prompt中的风格示例,而不是继续加断言。
还有个小技巧:把Promptfoo的输出导出,每次评估后都与上一版报告对比。我不只看单次结果,而是看趋势。如果一个版本所有指标都涨,哪怕只是微弱涨幅,我也会保留。如果有一项指标明显下降,就坚决回滚,不要心存侥幸。
7. 一点个人体会
这类智能体项目的坑不在某一个环节,而在链路衔接。分类字段和邮件Prompt如果设计不同步,后面改起来非常痛苦。我的体会是,在写第一行代码之前,先花一整天把数据契约定义清楚,这个投入绝对值得。否则等到测试阶段再去补字段,不只是改一个Agent的事,而是要连带改下游提示词、评估用例、字段映射逻辑。
另外,评估不是上线前做一次就结束,而是每次改Prompt都要跑一遍。Promptfoo就是那个能让你放心改Prompt的底牌。最后再分享一个小建议:给邮件生成Agent永远留一个人工审核出口,大模型可以帮你写好第一稿,但最终发出去之前,一定要有人眼确认。这个习惯能让你在推进自动化的时候,少担很多风险。