把 800 行 `index.ts` 拆成 MCP 架构这件事,我踩了不少坑

把 800 行 index.ts 拆成 MCP 架构这件事,我踩了不少坑

上个月用 claude-sonnet-4-6 的 Tool Use 给团队 ESLint 加 AI 增强,一下午就跑通了 demo。爽了三天。然后产品说要加组件生成,再加代码审查自动提 PR comment,我打开那个 index.ts 一看------800 多行,tools 数组和 if-else 分支全焊死在一起,每加一个能力就要改三处。完犊子了。

Client 端怎么串

Agent 主循环------整个系统的心脏

怎么说呢,这部分看着代码量不大,但坑的密度最高。本质就是一个 while 循环:给 LLM 发消息,看返回里有没有 tool_use block,有就转发给 MCP Server 执行,把结果拼成 tool_result 追加到对话,再来一轮。

ts 复制代码
async function agentLoop(
  anthropic: Anthropic,
  mcpClient: Client,
  mcpTools: Tool[],
  userMessage: string
) {
  const claudeTools = mcpTools.map(t => ({
    name: t.name,
    description: t.description ?? '',
    input_schema: t.inputSchema as Anthropic.Tool['input_schema']
  }))

  const messages: Anthropic.MessageParam[] = [
    { role: 'user', content: userMessage }
  ]

  let keepGoing = true

  while (keepGoing) {
    const response = await anthropic.messages.create({
      model: 'claude-sonnet-4-6-20250514',
      max_tokens: 8192,
      system: SYSTEM_PROMPT,
      tools: claudeTools,
      messages
    })

    messages.push({ role: 'assistant', content: response.content })

    if (response.stop_reason === 'end_turn') {
      keepGoing = false
      break
    }

    const toolUseBlocks = response.content.filter(
      (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'
    )

    if (toolUseBlocks.length === 0) {
      keepGoing = false
      break
    }

    const toolResults: Anthropic.ToolResultBlockParam[] = []

    for (const block of toolUseBlocks) {
      const result = await mcpClient.callTool({
        name: block.name,
        arguments: block.input as Record<string, unknown>
      })

      toolResults.push({
        type: 'tool_result',
        tool_use_id: block.id,
        content: result.content as Anthropic.ToolResultBlockParam['content']
      })
    }

    messages.push({ role: 'user', content: toolResults })
  }

  return messages
}

三个坑,一个比一个隐蔽。

第一个:tool_result 必须放在 role: 'user' 的消息里。

第二个:模型一次回复里可能包含多个 tool_use block------比如先 read_filelint_and_fix,你得全部执行完把结果一起返回,只处理第一个的话?模型直接懵掉,后续输出全是胡话。

嗯,继续。

第三个涉及 stop_reason 的判断。正常来说 stop_reason === 'tool_use' 的时候就表示模型在等你执行 tool,end_turn 表示说完了。我两个条件都判断了,多一层保险,因为在调试阶段遇到过一些奇怪的边界情况,虽然后来复现不出来了。

System Prompt 不是随便写写

这东西容易被忽略但真的关键。prompt 写得好不好直接决定模型会不会瞎调 tool:

ts 复制代码
const SYSTEM_PROMPT = `你是一个前端工程 AI Agent,可以通过工具来分析和修改代码。

工作流程:
1. 用户描述需求后,先用 read_file 了解相关代码
2. 如果是代码审查任务,用 lint_and_fix 获取问题列表,逐个分析
3. 如果需要修改代码,用 write_file 写入修改后的内容
4. 修改完成后再次运行 lint_and_fix 验证修复是否有效

重要约束:
- 每次修改前必须先读取文件当前内容
- 不要一次修改超过 3 个文件,分批处理
- 遇到不确定的问题,先用 read_file 看上下文再决策
- 修复 lint 问题时优先使用 autoFix,人工修复仅用于 autoFix 无法处理的情况`

"不要一次修改超过 3 个文件"这条是我自己加的经验规则。不加的话模型有时候会一口气改十几个文件,改着改着上下文就hold不住了,开始出现幻觉式的代码。限制步长反而效果好。3 这个数字是不是最优解?不知道。够用了。

场景一:代码审查干到自动修复

diff 太大的问题

跑通之后第一个撞上的墙就是 diff 体积。

我最后用了分层策略------先拿文件列表,也就是 filesOnly: true,让模型自己判断优先看哪些文件,然后逐个 read_file。比一股脑全灌进去效果好太多了,模型竟然真的会根据文件名和路径判断哪些更可能有问题。

ts 复制代码
// 别这样干------一次塞全量 diff 进去
// const diff = await getDiff({ filesOnly: false })

// 让模型自己挑文件看
const prompt = `这个 PR 修改了以下文件,请逐个审查关键文件:
${fileList}

对每个文件:
1. 先 read_file 看完整内容
2. 再 lint_and_fix 检查问题
3. 给出审查意见`

不是所有问题都该自动修

讲道理,semiquotes 这种纯格式问题让 ESLint --fix 直接处理掉就完了,根本不需要浪费 LLM 的 token 去"思考"一个分号该不该加。但 no-any 这种呢?你得理解业务逻辑才能给出正确的类型标注------先别急着反驳,这种事 ESLint 的 autofix 搞不定,得交给模型。

所以我在 server 端做了个简单的分类:

ts 复制代码
const AUTO_FIXABLE_RULES = new Set([
  'semi', 'quotes', 'indent', 'comma-dangle',
  'no-trailing-spaces', 'eol-last', 'no-multiple-empty-lines',
  '@typescript-eslint/consistent-type-imports'
])

function classifyIssues(messages: LintMessage[]) {
  return {
    autoFixable: messages.filter(m => AUTO_FIXABLE_RULES.has(m.ruleId ?? '')),
    needsReview: messages.filter(m => !AUTO_FIXABLE_RULES.has(m.ruleId ?? ''))
  }
}

Server 做多了还是做少了

这个问题我纠结了很久。

一开始我的思路是让 server 尽量"聪明"------lint_and_fix 不光返回错误列表,还在 server 端排优先级、分类、甚至生成修复建议再一起返回。后来发现这条路走不通。

两个原因。第一,server 端的规则逻辑跟 LLM 的判断标准会打架,你觉得 severity: error 最重要应该先处理,模型可能觉得某个 warning 级别的逻辑问题才是关键,两边标准不一致的时候输出一团糟。第二,server 端逻辑复杂了调试起来想死------MCP Server 跑在子进程里,你 console.log 打印的东西跑到 stdio 上去了,会被 client 端当成协议消息解析。够呛。我的日志直接把 JSON-RPC 通信搞崩了,排查了两小时才意识到问题出在哪。

最后定下来的原则很简单:server 只干确定性的活儿,需要判断的全交给模型。

arduino 复制代码
Server 负责(确定性操作):         模型负责(需要判断的):
├── 读写文件                        ├── 决定先看哪个文件
├── 运行 ESLint / Prettier          ├── 分析问题严重程度
├── 解析 AST 提取结构               ├── 设计修复方案
├── 执行 git 命令                   ├── 生成代码
└── 返回原始数据                    └── 规划下一步动作

stdio 和 SSE 怎么选

MCP 支持两种传输。stdio 是默认的,server 当 client 子进程跑,不对,应该说是LINE_CODE_73__ 是默认的,server 当 client 子进程跑。SSE 走 HTTP,server 独立部署。

好吧这个问题比我想的复杂。

我一开始用 stdio,因为简单。

ts 复制代码
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
import express from 'express'

const app = express()

app.get('/sse', async (req, res) => {
  const transport = new SSEServerTransport('/messages', res)
  await server.connect(transport)
})

app.post('/messages', async (req, res) => {
  await transport.handlePostMessage(req, res)
})

app.listen(3100)

稳定性------别让 Agent 翻车

token 超限

agent 循环跑多了消息历史像滚雪球一样膨胀。一个中等复杂度的审查任务可能 10 轮以上的 tool call,每轮 read_file 读回来的文件内容全存在 messages 数组里。跑到一半 context_length 超限,前面做的全白费。

我写了一个很粗糙的上下文裁剪函数,但它管用:

ts 复制代码
function trimMessages(
  messages: Anthropic.MessageParam[],
  maxTokenEstimate: number = 150_000
) {
  const estimateTokens = (msg: Anthropic.MessageParam) => {
    const text = JSON.stringify(msg.content)
    return Math.ceil(text.length * 0.7)
  }

  let total = messages.reduce((sum, m) => sum + estimateTokens(m), 0)

  while (total > maxTokenEstimate && messages.length > 7) {
    const removed = messages.splice(1, 1)
    total -= estimateTokens(removed[0])
  }

  return messages
}

思路就是保住头和尾------第一条是用户原始需求不能丢,最后六条是最近的交互上下文不能丢,中间的历史轮次?删了就删了。模型要是后面又需要之前读过的文件内容,它会再调一次 read_file,不影响最终结果。text.length * 0.7 这个系数是拍脑袋定的------好吧这个有点绕,中英文混合场景下误差大概在 20% 以内。够用了。

死循环

模型偶尔会陷进去------调 lint_and_fix 发现错误,改了,再调,还有错误(改出新 bug 了),再改,再查。循环往复。

硬限制伺候:

ts 复制代码
const MAX_ITERATIONS = 15
let iteration = 0

while (keepGoing && iteration < MAX_ITERATIONS) {
  iteration++
  // ... agent loop
}

if (iteration >= MAX_ITERATIONS) {
  console.warn('Agent 达到最大迭代次数,强制终止')
}

15 轮。

异常处理不能少

MCP Server 里的 tool handler 抛异常怎么办?总不能让整个 agent 进程跟着崩。

ts 复制代码
for (const block of toolUseBlocks) {
  try {
    const result = await mcpClient.callTool({
      name: block.name,
      arguments: block.input as Record<string, unknown>
    })
    toolResults.push({
      type: 'tool_result',
      tool_use_id: block.id,
      content: result.content as any
    })
  } catch (error) {
    toolResults.push({
      type: 'tool_result',
      tool_use_id: block.id,
      content: [{ type: 'text', text: `工具执行失败: ${error.message}` }],
      is_error: true
    })
  }
}

关键在 is_error: true,加了这个字段模型就知道这次调用挂了,会尝试换思路或者跳过。不加的话?模型会把错误堆栈当正常返回值理解,然后基于一段 TypeError: Cannot read properties of undefined 给你分析代码逻辑。场面一度非常魔幻。

相关推荐
Tzarevich2 小时前
深入理解Event Loop:从原理图到代码实战,小白也能看懂的 JS 执行机制
前端·javascript·面试
还是大剑师兰特2 小时前
vue3中slot,template #名称 的详细说明和具体示例
javascript·vue.js·ecmascript
南篱2 小时前
前端必看:一口气搞懂跨域是什么、为什么、怎么解决
前端·javascript·面试
qq_406176142 小时前
Vue 插槽与组件传参:从入门到精通
前端·javascript·vue.js
JamesYoung79712 小时前
第八部分 — UI 表面 sidePanel (如使用) + UX约束
前端·javascript·ui·ux
wuhen_n2 小时前
shallowRef 与 shallowReactive:浅层响应式的妙用
前端·javascript·vue.js
wuhen_n2 小时前
事件监听器销毁完全指南:如何避免内存泄漏?
前端·javascript·vue.js
飘逸飘逸3 小时前
Autojs进阶-插件更新记录
android·javascript
BUG创建者3 小时前
uniapp 开发app时播放实时视频海康ws的流数据
前端·javascript·vue.js·uni-app·html·音视频