☰
Agent项目越用越坑?工程纪律才是关键,不是模型
2026/10/27 7:57:24 网站建设 项目流程

1. 为什么你的 Agent 项目总是越用越坑

我见过太多团队在 Agent 项目上栽跟头,而且栽的姿势几乎一模一样。项目启动时信心满满,Demo 跑得飞起,老板看了拍板加资源,结果上线两周后开始出各种幺蛾子:同一个问题今天回答得头头是道,明天就开始胡言乱语;昨天还能正确调用的工具,今天突然传错参数;用户反馈的问题复现不了,日志翻烂了也找不到根因。最后团队陷入一种诡异的状态——每个人都在救火,但火越救越多,Agent 的表现像薛定谔的猫,打开对话框之前你永远不知道它是聪明还是智障。

这时候大多数人的第一反应是什么?换模型。觉得肯定是模型能力不够,GPT-4 不行就上 Claude,Claude 不行就试试 Gemini,再不行就搞个开源模型微调一下。钱花了不少,效果呢?短期可能有提升,但过不了多久又回到老样子。问题出在哪?缺的不是模型,是工程纪律。

这个判断不是拍脑袋来的。我参与过十几个 Agent 项目的从 0 到 1,也接手过不少别人做了一半的烂摊子。一个很明显的规律是:那些越用越坑的项目,几乎都不是模型选型的问题,而是工程实践上存在系统性缺陷。提示词散落在代码各处、工具定义没有版本管理、上下文管理全靠拍脑袋、错误处理就是 try-catch 一把梭、评测集永远只有那三五个 case。这些问题单个看都不致命,但叠加在一起,就会让 Agent 的行为变得不可预测、不可复现、不可维护。

这篇文章适合谁看?如果你正在做 Agent 开发,或者准备启动一个 Agent 项目,又或者你已经被 Agent 的“玄学”表现折磨得死去活来,那接下来的内容应该能帮你省下不少试错成本。我会从工程纪律的角度,把 Agent 项目从设计到上线的关键环节拆开来讲,重点不是告诉你“用什么模型”,而是告诉你“怎么管好你的 Agent 项目”。这些经验来自真实的踩坑记录,有些是用血泪换来的,希望能帮你少走点弯路。

2. Agent 工程纪律的核心框架:从混沌到可控

2.1 什么是 Agent 的工程纪律

先把这个概念说清楚。工程纪律这个词听起来有点虚,但落到 Agent 开发上,它指的是一套让 Agent 行为可预测、可复现、可迭代的实践规范。跟传统软件工程相比,Agent 项目有几个特殊之处:它的核心逻辑是自然语言驱动的,它的行为边界是概率性的,它的输入输出空间几乎是无限的。这三个特性决定了你不能用传统的那套工程方法来管 Agent,但你又不能完全放飞自我,否则就会掉进“越用越坑”的陷阱。

我理解的 Agent 工程纪律,至少包含四个层面。第一是提示词的工程化管理,提示词不是随手写在代码里的字符串,它应该是可版本化、可测试、可回滚的资产。第二是工具调用的契约化设计,每个工具的能力边界、输入输出格式、错误码定义都要有明确的规范,不能靠模型“猜”。第三是上下文的结构化治理,什么信息进上下文、什么信息出上下文、上下文超长了怎么压缩,这些都要有明确的策略。第四是评测的常态化运行,没有评测集的 Agent 项目就像没有测试的代码,你永远不知道改动会带来什么后果。

这四个层面缺一不可。我见过只做提示词管理不做评测的团队,改了一版提示词上线后效果暴跌,但因为没有评测集,根本不知道是哪个 case 退化。也见过工具定义很规范但上下文管理一塌糊涂的项目,工具调用成功率很高,但多轮对话超过五轮之后 Agent 就开始失忆。工程纪律是一个整体,任何一块短板都会成为木桶的漏水点。

2.2 为什么模型不是瓶颈

现在的大模型能力已经很强了。GPT-4 级别的模型在推理、指令遵循、工具调用上的表现,对于绝大多数业务场景来说是够用的。你遇到的那些“模型不行”的问题,拆开来看,十有八九是工程问题伪装成了模型问题。

举个例子。有团队反馈说 Agent 调用搜索工具时经常传错参数,怀疑是模型对工具描述理解不到位。我让他们把工具定义和实际调用日志拿出来一看,工具描述写的是“搜索相关信息”,参数只有一个 query 字符串。这能怪模型吗?模型根本不知道你要搜什么类型的信息、期望返回什么格式、有没有时间范围限制。后来他们把工具描述改成结构化的 JSON Schema,明确标注了每个参数的用途、格式、示例值,调用准确率直接从 60% 出头干到了 90% 以上。模型没换,只是把工程做扎实了。

再举个例子。有项目反馈说 Agent 在多轮对话中经常忘记之前说过的信息。排查发现,他们的上下文管理策略是“把最近 10 轮对话全部塞进去”,超过就截断最早的。问题是有些关键信息在第三轮就出现了,到第十一轮被截断后 Agent 就失忆了。这跟模型能力有什么关系?这是上下文管理策略的问题。后来改成关键信息抽取加摘要压缩的方式,问题就解决了。

所以我的观点很明确:在当前的模型能力水平下,Agent 项目的效果上限主要由工程纪律决定,而不是模型选型。你换一个更强的模型,可能短期能掩盖一些工程问题,但这些问题迟早会以更隐蔽的方式暴露出来。而且模型越强,你对它的行为预期越高,工程问题带来的落差感反而越大。

2.3 工程纪律缺失的典型症状

怎么判断你的 Agent 项目是不是缺乏工程纪律?我整理了几个典型症状,你可以对照看看。

症状具体表现根因
行为不可复现同一个输入,不同时间调用结果差异大提示词或上下文管理存在随机性
改动引发退化修好一个 case,另一个 case 坏了缺乏回归评测机制
调试靠猜出问题后不知道是哪一环导致的缺乏全链路日志和追踪
工具调用不稳定参数传错、格式不对、超时无处理工具契约设计不规范
上下文失控多轮对话后失忆或答非所问上下文治理策略缺失
迭代效率低每次改动都要人工全量验证评测自动化程度不足

这些症状单独出现可能还能忍,但如果同时出现三个以上,那你的项目基本上已经进入“越用越坑”的恶性循环了。好消息是,这些问题都有对应的工程解法,而且不需要换模型就能解决。

3. 提示词工程化:把“咒语”变成可管理的资产

3.1 提示词散落是万恶之源

我接手过一个项目,提示词分布在十几个文件里,有的在 Python 代码的字符串常量里,有的在 YAML 配置文件里,还有的硬编码在前端 JavaScript 里。更离谱的是,同一个功能的提示词在不同地方有三个版本,内容还不一样。问团队哪个是当前生效的,没人说得清楚。这种项目不出问题才怪。

提示词散落带来的直接后果是:你无法回答“当前线上运行的提示词到底是什么”这个问题。当 Agent 行为异常时,你连排查的起点都找不到。而且每次改动提示词,你都不知道会影响哪些功能,因为同一个提示词可能被多个地方引用。

我的做法是提示词集中管理。所有提示词放在一个独立的目录下,按功能模块分文件,用模板引擎(比如 Jinja2)做变量替换。每个提示词文件有明确的版本号,改动走代码审查流程。这样你随时可以回答“线上跑的是哪个版本的提示词”,也能追溯每次改动的内容和原因。

# prompts/tool_calling/search.jinja2 # version: 2.3.1 # last_modified: 2024-01-15 # description: 搜索工具调用提示词,用于引导模型正确构造搜索参数 你是一个搜索助手。根据用户的问题,构造合适的搜索查询。 用户问题:{{ user_query }} 可用搜索工具的参数说明: - query: 搜索关键词,必填,字符串类型 - time_range: 时间范围,可选,枚举值:day/week/month/year - max_results: 最大返回数量,可选,整数,默认5 请以 JSON 格式输出搜索参数,不要输出其他内容。

这种管理方式的好处是,提示词变成了可审查、可追溯、可回滚的资产。每次改动都有记录,出问题可以快速定位到具体版本。而且集中管理后,你可以很容易地做提示词的静态分析,比如检查是否有未替换的变量、是否有语法错误。

3.2 提示词版本管理与回滚机制

提示词版本管理不是简单地加个版本号就完事了。你需要一套完整的机制来支撑提示词的迭代。

首先是版本号规范。我建议用语义化版本,主版本号表示不兼容的改动,次版本号表示功能增减,修订号表示措辞调整。比如从 1.2.3 到 2.0.0,说明有破坏性改动,需要重点回归测试。从 1.2.3 到 1.3.0,说明新增了功能,需要测试新功能。从 1.2.3 到 1.2.4,说明只是措辞优化,做基本验证即可。

其次是变更记录。每次改动提示词,都要记录改了什么、为什么改、预期效果是什么。这些记录在排查问题时非常有用。我见过团队改了一版提示词后效果变差,但因为没记录改了什么,只能靠 git diff 一行行看,效率极低。

然后是回滚机制。提示词上线后如果效果不达预期,要能快速回滚到上一个稳定版本。这要求你的提示词加载逻辑支持指定版本,而不是只能加载最新版。实现方式可以是在配置里指定版本号,或者用 feature flag 控制。

# prompt_manager.py import jinja2 from pathlib import Path class PromptManager: def __init__(self, prompt_dir: str, default_version: str = "latest"): self.env = jinja2.Environment( loader=jinja2.FileSystemLoader(prompt_dir), undefined=jinja2.StrictUndefined # 变量未定义时报错,避免静默失败 ) self.default_version = default_version self.version_cache = {} def render(self, prompt_name: str, version: str = None, **kwargs) -> str: version = version or self.default_version # 版本化加载逻辑:prompts/search/2.3.1.jinja2 template_path = f"{prompt_name}/{version}.jinja2" try: template = self.env.get_template(template_path) return template.render(**kwargs) except jinja2.TemplateNotFound: raise ValueError(f"Prompt {prompt_name} version {version} not found")

注意:提示词版本管理要和代码版本管理解耦。不要把提示词版本和代码版本绑死,否则每次改提示词都要发版,迭代效率会很低。提示词应该支持热加载,改完立即生效,但要有灰度机制控制影响范围。

3.3 提示词测试与回归验证

提示词改动的风险不比代码改动小,但很多团队对提示词改动几乎没有测试。改完直接上线,效果好不好全靠运气。这种做法在项目早期可能还能凑合,但随着提示词越来越复杂,迟早会出问题。

我的做法是建立提示词测试集。每个核心提示词都配一组测试用例,覆盖正常场景、边界场景、异常场景。每次改动提示词后,自动跑一遍测试集,对比改动前后的输出差异。如果差异在预期范围内,通过;如果出现非预期的退化,打回。

测试集的构建有个技巧:不要只收集“正确”的 case,更要收集“曾经出过错”的 case。每次线上发现 bad case,就把它加到测试集里。这样测试集越来越丰富,提示词的鲁棒性也越来越强。我带的项目里,一个核心提示词的测试集通常有 50 到 100 个 case,覆盖各种奇奇怪怪的输入。

# test_prompts.py import pytest from prompt_manager import PromptManager @pytest.fixture def prompt_manager(): return PromptManager("prompts/") class TestSearchPrompt: def test_basic_query(self, prompt_manager): result = prompt_manager.render( "tool_calling/search", version="2.3.1", user_query="今天北京的天气怎么样" ) assert "query" in result assert "time_range" in result def test_empty_query(self, prompt_manager): # 边界场景:空输入 result = prompt_manager.render( "tool_calling/search", version="2.3.1", user_query="" ) # 验证提示词对空输入的处理是否符合预期 assert "请提供有效的搜索内容" in result or "query" in result def test_regression_case_001(self, prompt_manager): # 回归 case:曾经出过问题的输入 result = prompt_manager.render( "tool_calling/search", version="2.3.1", user_query="帮我找一下上周关于AI Agent的新闻,要英文的" ) # 验证模型能正确识别 time_range=week 和语言偏好 assert "week" in result.lower()

提示词测试不需要每次都人工检查输出,可以用规则匹配加模型评判的方式做自动化验证。规则匹配处理格式类检查,模型评判处理语义类检查。两者结合,能在保证效率的同时覆盖大部分场景。

4. 工具调用的契约化设计:让模型不再“猜”

4.1 工具描述的结构化规范

工具调用是 Agent 区别于普通聊天机器人的核心能力,也是最容易出问题的环节。我见过太多项目在工具描述上偷懒,写一句“搜索相关信息”就完事了,然后抱怨模型调用不准确。这就像你给一个新员工布置任务,只说“去办点事”,然后怪他办得不对。

工具描述必须结构化。每个工具至少包含以下信息:工具名称、功能描述、参数列表(每个参数的名称、类型、是否必填、描述、示例值)、返回值格式、错误码定义。这些信息用 JSON Schema 表达最合适,因为大多数模型对 JSON Schema 的理解都很到位。

{ "name": "search_news", "description": "根据关键词搜索新闻文章,返回相关新闻列表", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "搜索关键词,支持中英文,多个关键词用空格分隔", "examples": ["AI Agent 最新进展", "large language model safety"] }, "time_range": { "type": "string", "enum": ["day", "week", "month", "year"], "description": "搜索的时间范围,不传则默认搜索全部时间", "default": "month" }, "language": { "type": "string", "enum": ["zh", "en", "all"], "description": "搜索结果的语言偏好", "default": "all" }, "max_results": { "type": "integer", "minimum": 1, "maximum": 20, "description": "最大返回结果数量", "default": 5 } }, "required": ["query"] }, "returns": { "type": "array", "items": { "type": "object", "properties": { "title": {"type": "string"}, "url": {"type": "string"}, "summary": {"type": "string"}, "publish_date": {"type": "string", "format": "date"} } } }, "error_codes": { "INVALID_QUERY": "搜索关键词为空或格式不正确", "RATE_LIMITED": "请求频率超限,请稍后重试", "NO_RESULTS": "未找到相关结果" } }

这种结构化的工具描述,模型理解起来准确率会高很多。实测下来,同样的模型,工具描述从自然语言改成结构化 JSON Schema 后,参数构造准确率能提升 20 到 30 个百分点。而且结构化描述还有个好处:你可以用它自动生成工具调用的校验逻辑,模型传参后先做 schema 校验,不合法直接返回错误让模型重试,而不是把错误参数传给后端服务。

4.2 工具调用的错误处理与重试策略

工具调用不可能 100% 成功。网络超时、服务限流、参数错误、返回格式异常,这些都会发生。如果 Agent 对这些错误没有处理能力,用户体验就会很差。我见过 Agent 调用工具失败后直接给用户返回一个 Python traceback 的,这种体验简直是灾难。

工具调用的错误处理要分层次。第一层是参数校验,模型构造的参数先过一遍 JSON Schema 校验,不合法就返回明确的错误信息让模型重新构造。第二层是调用重试,对于超时、限流这类临时性错误,做有限次数的重试,重试间隔用指数退避。第三层是降级处理,重试仍然失败后,返回一个用户能理解的错误提示,并给出替代方案。

import time from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type class ToolExecutor: def __init__(self, max_retries: int = 3): self.max_retries = max_retries def validate_params(self, tool_schema: dict, params: dict) -> tuple[bool, str]: """校验参数是否符合 schema 定义""" # 简化版校验逻辑,实际项目建议用 jsonschema 库 required = tool_schema["parameters"].get("required", []) for field in required: if field not in params: return False, f"缺少必填参数: {field}" properties = tool_schema["parameters"]["properties"] for key, value in params.items(): if key not in properties: return False, f"未知参数: {key}" expected_type = properties[key]["type"] if expected_type == "string" and not isinstance(value, str): return False, f"参数 {key} 类型错误,期望 string" if expected_type == "integer" and not isinstance(value, int): return False, f"参数 {key} 类型错误,期望 integer" return True, "" @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_exception_type((TimeoutError, ConnectionError)) ) def execute_with_retry(self, tool_func, params: dict): """带重试的工具执行""" return tool_func(**params) def execute(self, tool_schema: dict, tool_func, params: dict) -> dict: """完整的工具执行流程""" # 第一层:参数校验 valid, error_msg = self.validate_params(tool_schema, params) if not valid: return { "success": False, "error_code": "INVALID_PARAMS", "error_message": error_msg, "suggestion": "请根据工具描述重新构造参数" } # 第二层:带重试的执行 try: result = self.execute_with_retry(tool_func, params) return {"success": True, "data": result} except Exception as e: # 第三层:降级处理 return { "success": False, "error_code": "EXECUTION_FAILED", "error_message": str(e), "suggestion": "工具暂时不可用,请尝试其他方式获取信息" }

提示:重试策略要区分错误类型。参数错误重试没有意义,因为模型构造的参数不会因为重试就变对。只有超时、限流这类临时性错误才值得重试。而且重试次数不宜过多,一般 2 到 3 次就够了,再多会拖长响应时间。

4.3 工具编排与依赖管理

当 Agent 需要调用多个工具时,工具之间的依赖关系就变得重要了。有些工具可以并行调用,有些必须串行,有些工具的输出是另一些工具的输入。如果这些依赖关系没有管理好,Agent 就会做出一些很蠢的操作,比如在获取用户 ID 之前就去查用户订单。

我的做法是用 DAG 描述工具依赖。每个工具声明自己的前置依赖和输出字段,编排引擎根据依赖关系决定调用顺序。这样 Agent 只需要决定“要做什么”,而不需要关心“先做什么后做什么”,降低了模型的决策负担。

from collections import defaultdict, deque class ToolOrchestrator: def __init__(self): self.tools = {} # tool_name -> tool_config self.dependencies = defaultdict(set) # tool_name -> set of dependency tool names def register_tool(self, name: str, config: dict, depends_on: list = None): self.tools[name] = config if depends_on: self.dependencies[name] = set(depends_on) def topological_sort(self, required_tools: list) -> list: """根据依赖关系做拓扑排序,返回执行顺序""" # 收集所有需要的工具及其依赖 all_tools = set(required_tools) queue = deque(required_tools) while queue: tool = queue.popleft() for dep in self.dependencies.get(tool, set()): if dep not in all_tools: all_tools.add(dep) queue.append(dep) # 拓扑排序 in_degree = {tool: 0 for tool in all_tools} for tool in all_tools: for dep in self.dependencies.get(tool, set()): if dep in all_tools: in_degree[tool] += 1 queue = deque([t for t in all_tools if in_degree[t] == 0]) result = [] while queue: tool = queue.popleft() result.append(tool) for other in all_tools: if tool in self.dependencies.get(other, set()): in_degree[other] -= 1 if in_degree[other] == 0: queue.append(other) if len(result) != len(all_tools): raise ValueError("工具依赖存在循环,无法编排") return result def execute_plan(self, required_tools: list, context: dict) -> dict: """按依赖顺序执行工具""" execution_order = self.topological_sort(required_tools) results = {} for tool_name in execution_order: tool_config = self.tools[tool_name] # 从 context 和之前的结果中收集参数 params = self._collect_params(tool_config, context, results) result = self._execute_tool(tool_name, params) results[tool_name] = result # 如果某个工具失败且是后续工具的强依赖,终止执行 if not result.get("success") and tool_config.get("critical", False): return { "success": False, "failed_at": tool_name, "results": results } return {"success": True, "results": results}

这种编排方式的好处是,工具调用的顺序由依赖关系决定,而不是由模型“猜”。模型只需要输出“我需要调用 A、B、C 三个工具”,编排引擎会自动算出正确的执行顺序。这大大降低了模型决策的复杂度,也减少了因为顺序错误导致的失败。

5. 上下文治理:别让 Agent 得了“失忆症”

5.1 上下文窗口的分配策略

上下文窗口是 Agent 的“工作记忆”,但这个记忆容量是有限的。大多数模型的上下文窗口在 8K 到 128K 之间,看起来很大,但实际用起来很快就不够。系统提示词占一块、工具描述占一块、对话历史占一块、检索到的文档占一块、当前用户输入占一块,加起来很容易就超了。

我的做法是给上下文窗口做预算分配。把上下文窗口想象成一个固定大小的容器,每个部分有明确的配额。比如 8K 窗口可以这样分:系统提示词 1K、工具描述 1.5K、对话历史 3K、检索文档 2K、当前输入 0.5K。每个部分超出配额时触发压缩或截断策略。

上下文组成部分建议配额(8K 窗口)超出时的处理策略
系统提示词1K精简措辞,移除冗余说明
工具描述1.5K只保留当前任务相关工具的描述
对话历史3K摘要压缩,保留关键信息
检索文档2K按相关性排序,截断低分文档
当前输入0.5K超长输入做分段处理

这个配额不是固定的,要根据任务类型动态调整。比如工具调用密集的任务,工具描述配额可以调高;知识问答类任务,检索文档配额可以调高。关键是你要有意识地去管理这个分配,而不是让各个部分随意挤占空间。

5.2 对话历史的压缩与摘要

对话历史是上下文膨胀的主要来源。多轮对话下来,历史消息越积越多,很快就会把上下文窗口撑爆。简单的截断策略(只保留最近 N 轮)会丢失早期的重要信息,导致 Agent 失忆。

我试过几种压缩策略,效果比较好的是关键信息抽取加滚动摘要。具体做法是:每轮对话结束后,用一个小模型(或者规则)从对话中抽取关键信息,比如用户提到的实体、达成的共识、待办事项等。这些关键信息存到一个结构化的“记忆区”,不随对话轮次增加而膨胀。同时,对较早的对话历史做摘要压缩,保留语义主干,丢弃细节。

class ConversationMemory: def __init__(self, max_recent_turns: int = 5, summary_threshold: int = 10): self.recent_turns = [] # 最近几轮完整对话 self.summary = "" # 较早对话的摘要 self.key_facts = {} # 结构化关键信息 self.max_recent_turns = max_recent_turns self.summary_threshold = summary_threshold def add_turn(self, user_input: str, agent_output: str): self.recent_turns.append({ "user": user_input, "agent": agent_output }) # 抽取关键信息 self._extract_key_facts(user_input, agent_output) # 超出阈值时压缩 if len(self.recent_turns) > self.summary_threshold: self._compress_old_turns() def _extract_key_facts(self, user_input: str, agent_output: str): """从对话中抽取关键信息,实际项目可以用小模型来做""" # 简化示例:用规则抽取 # 实际项目中这里可以调用一个轻量级模型做信息抽取 pass def _compress_old_turns(self): """把较早的对话压缩成摘要""" # 保留最近 max_recent_turns 轮,其余的压缩 to_compress = self.recent_turns[:-self.max_recent_turns] self.recent_turns = self.recent_turns[-self.max_recent_turns:] # 实际项目中这里调用模型做摘要 # 简化示例:拼接后截断 compressed = " ".join([ f"用户: {t['user']} 助手: {t['agent']}" for t in to_compress ]) self.summary = compressed[:500] # 简化处理 def get_context(self) -> str: """组装上下文""" parts = [] if self.summary: parts.append(f"[历史对话摘要] {self.summary}") if self.key_facts: facts_str = "; ".join([f"{k}: {v}" for k, v in self.key_facts.items()]) parts.append(f"[关键信息] {facts_str}") for turn in self.recent_turns: parts.append(f"用户: {turn['user']}") parts.append(f"助手: {turn['agent']}") return "\n".join(parts)

这种策略的核心思想是:完整对话只保留最近几轮,较早的对话压缩成摘要,关键信息结构化存储。这样上下文长度可控,同时重要信息不会丢失。实测下来,在 10 轮以上的对话中,这种策略比简单截断的 Agent 表现好很多,尤其是在需要回忆早期信息的场景下。

5.3 检索增强中的上下文精选

RAG(检索增强生成)是 Agent 的常见能力,但检索回来的文档怎么放进上下文,也是有很多讲究的。我见过直接把 top-10 文档全部塞进上下文的做法,结果上下文被文档占满,对话历史没地方放,Agent 变成了一个只会念文档的复读机。

检索文档的上下文管理要解决两个问题:选哪些和怎么放。

选哪些文档,不能只看检索分数。检索分数高不代表对当前问题有用。我的做法是加一层相关性重排序,用一个交叉编码器或者小模型对检索结果做精排,只保留真正相关的 top-3 到 top-5。同时要考虑文档的多样性,避免选出来的都是同一篇文档的不同段落。

怎么放文档,也有讲究。不要把文档原文直接塞进去,而是做关键段落抽取。一篇 2000 字的文档,真正相关的可能就其中 200 字。把不相关的部分去掉,既节省上下文空间,又减少噪声干扰。另外,文档的放置位置也影响模型注意力,一般建议把最相关的文档放在靠近当前问题的位置。

class ContextSelector: def __init__(self, max_docs: int = 3, max_doc_length: int = 500): self.max_docs = max_docs self.max_doc_length = max_doc_length def select_and_format(self, query: str, retrieved_docs: list) -> str: """从检索结果中精选文档并格式化""" # 第一步:重排序(实际项目用交叉编码器) reranked = self._rerank(query, retrieved_docs) # 第二步:去重和多样性筛选 selected = self._diversity_filter(reranked) # 第三步:关键段落抽取 formatted_docs = [] for doc in selected[:self.max_docs]: key_content = self._extract_key_content(query, doc) formatted_docs.append( f"[文档 {len(formatted_docs)+1}] {doc['title']}\n{key_content}" ) return "\n\n".join(formatted_docs) def _rerank(self, query: str, docs: list) -> list: """重排序,实际项目用交叉编码器模型""" # 简化示例:按原始分数排序 return sorted(docs, key=lambda x: x.get("score", 0), reverse=True) def _diversity_filter(self, docs: list) -> list: """多样性筛选,避免选到重复内容""" selected = [] seen_sources = set() for doc in docs: source = doc.get("source", "") if source not in seen_sources: selected.append(doc) seen_sources.add(source) return selected def _extract_key_content(self, query: str, doc: dict) -> str: """抽取文档中与查询最相关的段落""" content = doc.get("content", "") if len(content) <= self.max_doc_length: return content # 简化示例:截取前 max_doc_length 字符 # 实际项目应该用句子级相关性做抽取 return content[:self.max_doc_length] + "..."

注意:检索文档的上下文管理要和对话历史的上下文管理协调。如果检索文档占用了太多空间,对话历史就得压缩得更狠。反过来也一样。建议在上下文预算分配时,给检索文档和对话历史分别设定上限,超了就触发各自的压缩策略。

6. 评测常态化:没有度量就没有改进

6.1 评测集的构建与维护

没有评测集的 Agent 项目,就像没有测试的代码,你永远不知道改动会带来什么后果。但评测集的构建不是一蹴而就的,它需要持续维护和迭代。

我的做法是三层评测集。第一层是冒烟测试集,10 到 20 个最核心的 case,每次改动后必须跑,确保基本功能没坏。第二层是回归测试集,100 到 200 个 case,覆盖主要功能和常见边界,每天跑一次。第三层是全量评测集,500 个以上的 case,覆盖各种长尾场景,每周跑一次。

评测集的来源主要有三个:人工构造、线上日志挖掘、对抗生成。人工构造的 case 质量高但数量有限;线上日志挖掘能发现真实场景中的问题;对抗生成可以覆盖一些极端情况。三者结合,评测集才能既有广度又有深度。

class EvalDataset: def __init__(self): self.smoke_cases = [] # 冒烟测试 self.regression_cases = [] # 回归测试 self.full_cases = [] # 全量评测 def add_case(self, case: dict, level: str = "regression"): """添加评测 case""" required_fields = ["input", "expected_behavior", "category"] for field in required_fields: if field not in case: raise ValueError(f"Case 缺少必填字段: {field}") if level == "smoke": self.smoke_cases.append(case) elif level == "regression": self.regression_cases.append(case) else: self.full_cases.append(case) def from_production_log(self, log_entry: dict): """从线上日志中挖掘评测 case""" # 线上发现的 bad case 自动加入回归测试集 case = { "input": log_entry["user_input"], "expected_behavior": log_entry.get("expected", ""), "category": "production_regression", "source": "production_log", "timestamp": log_entry["timestamp"] } self.add_case(case, level="regression") def get_cases(self, level: str = "regression") -> list: if level == "smoke": return self.smoke_cases elif level == "regression": return self.smoke_cases + self.regression_cases else: return self.smoke_cases + self.regression_cases + self.full_cases

评测集的维护有个原则:每次线上发现 bad case,都要把它加到评测集里。这样评测集越来越丰富,覆盖的场景越来越全。我带的项目里,评测集从最初的 20 个 case 增长到 500 多个,基本上覆盖了所有已知的问题场景。每次改动后跑一遍评测集,心里就有底了。

6.2 自动化评测流水线

评测不能靠人工跑,必须自动化。我的做法是把评测集成到 CI/CD 流程里。每次代码或提示词改动,自动触发评测流水线,跑冒烟测试集和回归测试集,输出评测报告。如果关键指标下降超过阈值,自动阻断发布。

评测流水线要解决几个问题:怎么跑、怎么评、怎么报告。

怎么跑,关键是并行化。500 个 case 串行跑可能要几个小时,并行跑可以压缩到几十分钟。用多进程或者分布式任务队列都可以,根据评测集的规模选择。

怎么评,分两种方式。规则匹配处理格式类、事实类的检查,比如工具调用参数是否正确、输出是否符合 JSON 格式。模型评判处理语义类的检查,比如回答是否相关、是否有害。两者结合,兼顾效率和准确性。

import asyncio from concurrent.futures import ProcessPoolExecutor class EvalPipeline: def __init__(self, agent, evaluator, max_workers: int = 8): self.agent = agent self.evaluator = evaluator self.max_workers = max_workers async def run_eval(self, cases: list) -> dict: """运行评测流水线""" results = [] # 并行执行评测 with ProcessPoolExecutor(max_workers=self.max_workers) as executor: loop = asyncio.get_event_loop() tasks = [ loop.run_in_executor(executor, self._eval_single, case) for case in cases ] results = await asyncio.gather(*tasks) # 汇总结果 return self._aggregate_results(results) def _eval_single(self, case: dict) -> dict: """执行单个评测 case""" # 调用 Agent agent_output = self.agent.run(case["input"]) # 规则匹配评测 rule_result = self._rule_based_eval(case, agent_output) # 模型评判 model_result = self.evaluator.evaluate( input=case["input"], expected=case["expected_behavior"], actual=agent_output ) return { "case_id": case.get("id", ""), "category": case["category"], "rule_pass": rule_result["pass"], "model_score": model_result["score"], "details": { "rule": rule_result, "model": model_result } } def _rule_based_eval(self, case: dict, output: str) -> dict: """规则匹配评测""" checks = [] # 检查输出格式 if case.get("expected_format") == "json": try: import json json.loads(output) checks.append({"name": "json_format", "pass": True}) except json.JSONDecodeError: checks.append({"name": "json_format", "pass": False}) # 检查关键词 if case.get("must_contain"): for keyword in case["must_contain"]: checks.append({ "name": f"contains_{keyword}", "pass": keyword in output }) return { "pass": all(c["pass"] for c in checks), "checks": checks } def _aggregate_results(self, results: list) -> dict: """汇总评测结果""" total = len(results) rule_pass = sum(1 for r in results if r["rule_pass"]) avg_model_score = sum(r["model_score"] for r in results) / total # 按类别统计 by_category = {} for r in results: cat = r["category"] if cat not in by_category: by_category[cat] = {"total": 0, "pass": 0} by_category[cat]["total"] += 1 if r["rule_pass"]: by_category[cat]["pass"] += 1 return { "total": total, "rule_pass_rate": rule_pass / total, "avg_model_score": avg_model_score, "by_category": by_category, "failed_cases": [r for r in results if not r["rule_pass"]] }

评测报告要直观,让人一眼能看出问题。我一般会输出几个关键指标:整体通过率、各类别通过率、模型平均分、失败 case 列表。失败 case 要附上输入、期望输出、实际输出,方便快速定位问题。

6.3 评测驱动的迭代闭环

评测的最终目的是驱动迭代。没有评测,迭代就是盲人摸象;有了评测,迭代就有了方向。

我的做法是建立评测驱动的迭代闭环。每次迭代前,先跑一遍评测集,看看当前基线在哪里。然后针对评测中发现的薄弱环节做优化,改完再跑一遍评测,对比改动前后的指标变化。如果指标提升,发布;如果指标下降,回滚或继续优化。

这个闭环的关键是快速反馈。评测跑得越快,迭代效率越高。所以评测流水线要尽量并行化,评测集要分层,冒烟测试集要能在几分钟内跑完。我带的项目里,冒烟测试集 5 分钟内出结果,回归测试集 30 分钟内出结果,全量评测集 2 小时内出结果。这样的反馈速度,基本上能做到当天改当天验证。

还有一个经验是:不要追求一次改很多。每次迭代只改一个变量,比如只改提示词、或者只改工具描述、或者只改上下文策略。这样评测结果的变化才能归因到具体的改动上。如果一次改了好几个地方,指标提升了也不知道是哪个改动起的作用,指标下降了也不知道该回滚哪个。

7. 常见问题与排查技巧实录

7.1 Agent 行为异常的排查思路

Agent 行为异常时,排查要有章法,不能东一榔头西一棒子。我的排查思路是从外到内,逐层缩小范围。

第一层:确认输入。用户输入是什么?有没有特殊字符?有没有超长?有没有多语言混合?很多时候问题出在输入上,比如用户输入了一段包含特殊符号的文本,导致提示词模板渲染出错。

第二层:检查提示词。当前生效的提示词是哪个版本?渲染后的完整提示词是什么?有没有变量未替换?有没有格式错误?我习惯在日志里记录每次调用的完整提示词,排查时直接看日志就行。

第三层:检查上下文。上下文里有哪些内容?对话历史有没有被正确压缩?检索文档有没有被正确选取?上下文长度有没有超限?上下文的问题往往比较隐蔽,需要仔细检查。

第四层:检查工具调用。模型输出了什么工具调用?参数是什么?工具返回了什么?有没有错误?工具调用的问题通常比较明显,看日志就能发现。

第五层:检查模型输出。模型的原始输出是什么?有没有被后处理逻辑修改?后处理逻辑有没有 bug?

这个排查顺序是从外到内的,先排除简单问题,再深入复杂问题。大部分问题在前两层就能定位,少数复杂问题才需要深入到第四第五层。

排查层级检查项常见问题排查工具
输入层用户输入特殊字符、超长、编码问题输入日志
提示词层提示词版本、渲染结果变量未替换、格式错误提示词日志
上下文层上下文内容、长度压缩过度、检索不准上下文快照
工具层调用参数、返回结果参数错误、超时、格式异常工具调用日志
输出层模型原始输出、后处理后处理 bug、格式转换错误输出日志

7.2 性能瓶颈的定位与优化

Agent 的性能问题通常表现为响应慢。响应慢的原因可能有很多:模型推理慢、工具调用慢、上下文太长、检索慢、后处理慢。定位性能瓶颈,最直接的方法是加耗时打点。

在每个关键环节加时间戳,记录耗时。模型推理耗时多少、工具调用耗时多少、上下文组装耗时多少、检索耗时多少。这样一眼就能看出瓶颈在哪里。

import time from contextlib import contextmanager class PerformanceTracker: def __init__(self): self.timings = {} @contextmanager def track(self, name: str): start = time.time() try: yield finally: elapsed = time.time() - start if name not in self.timings: self.timings[name] = [] self.timings[name].append(elapsed) def report(self) -> dict: """输出耗时报告""" report = {} for name, times in self.timings.items(): report[name] = { "count": len(times), "total": sum(times), "avg": sum(times) / len(times), "max": max(times), "min": min(times) } return report # 使用示例 tracker = PerformanceTracker() with tracker.track("model_inference"): # 模型推理 pass with tracker.track("tool_call"): # 工具调用 pass with tracker.track("context_assembly"): # 上下文组装 pass print(tracker.report())

常见的性能瓶颈和优化方向:

  • 模型推理慢:换更小的模型、用流式输出、减少 max_tokens、用推理加速框架
  • 工具调用慢:并行调用无依赖的工具、加缓存、优化工具本身性能
  • 上下文太长:压缩上下文、减少检索文档数量、精简提示词
  • 检索慢:优化索引、加缓存、减少检索候选数量

提示:性能优化要有优先级。先优化耗时占比最大的环节,再优化其他环节。不要一上来就做微优化,收益低还浪费时间。

7.3 线上问题的应急处理

线上出问题时,第一要务是止损,第二是定位,第三是修复。止损的手段包括:回滚到上一个稳定版本、降级到备用方案、限流保护。定位的手段包括:查日志、看监控、复现问题。修复的手段包括:热修复、发版修复。

我建议每个 Agent 项目都准备好应急预案。预案里明确:什么情况下触发回滚、回滚到哪个版本、谁来执行回滚、回滚后怎么验证。这样出问题时不会手忙脚乱。

还有一个经验是:保留现场。出问题时,先把相关日志、上下文快照、工具调用记录保存下来,再去做回滚或修复。否则问题复现不了,根因就找不到,下次还会踩同样的坑。

8. 一些踩坑之后的个人体会

做 Agent 项目这几年,最大的体会是:模型能力决定下限,工程纪律决定上限。你用一个很强的模型,可能能做出一个 60 分的 Agent;但如果你想做到 80 分、90 分,靠的不是换更强的模型,而是把工程做扎实。

我见过太多团队在模型选型上纠结来纠结去,今天试试这个,明天试试那个,但工程实践上却非常粗糙。提示词随手写、工具描述一句话、上下文管理靠截断、评测集只有三五个 case。这种项目,换什么模型都救不了。

反过来,我也见过一些团队,用的模型不是最强的,但工程纪律非常好。提示词版本化管理、工具描述结构化、上下文治理有策略、评测集持续维护。他们的 Agent 表现稳定、可复现、可迭代,用户体验很好。

所以我的建议是:先把工程纪律建立起来,再考虑模型选型。工程纪律是地基,模型是装修。地基不牢,装修再好也白搭。

最后分享一个小技巧:每次线上发现 bad case,不要只修这一个 case,要想想这类 case 的通用解法。比如发现 Agent 在某个工具调用上出错了,不要只改这个工具的提示词,要检查所有工具的提示词是不是都有类似问题。从个案中提炼出通用规则,才能避免同类问题反复出现。

这个内容后续还可以这样扩展:把工程纪律的检查项做成一个 checklist,每次项目迭代前过一遍,确保没有遗漏。也可以把评测流水线做成一个独立的服务,支持多个 Agent 项目共用。还可以把上下文治理的策略做成可配置的,根据任务类型自动选择最合适的策略。这些都是我在实际项目中验证过可行的方向,有兴趣的可以试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询