普通视图

发现新文章,点击刷新页面。
昨天 — 2025年4月2日首页

函数计算支持热门 MCP Server 一键部署

2025年4月1日 13:18

全球 AI 开发者们热议“MCP”(Model Context Protocol)。尽管这一协议在2024年由Anthropic发布时并未引起广泛关注,但2025年初,Cursor宣布集成MCP迅速将其带入开发者的视野,3月Manus的爆发更是加速了MCP的普及。而就在3月27日,OpenAI正式宣布其Agent SDK全面支持MCP协议,这一举措标志着MCP将会成为该领域的实施标准,必将重塑AI应用的开发与交互方式。

目前,社区的MCP Server大多采用本地STDIO模式部署,尽管这种模式能支持基本模型服务和工具的数据交互,简单测试尚可,但在涉及具体的开发,调试,由于IO重定向带来不同程度的开发复杂度;同时随着AI场景的日益丰富,一方面数据访问不再仅限于本地,另一方面业务对于架构可靠性要求,基于本地部署的 MCP Server 势必无法满足复杂的生产需求。因此,云上托管的MCP Server将成为未来的主流趋势。函数计算(FC)目前已支持一键托管开源的MCP Server,欢迎大家前来体验。

为什么云上托管 MCP Server 是趋势?

  • 吸引更多开发者参与MCP生态建设

MCP协议成为事实标准后,开发者无需为每个Function编写复杂的JSON Schema参数说明,这大大降低了重复开发的工作量。通过开源或第三方的MCP Server,开发者能够迅速共享和复用资源。例如,Blender-MCP项目允许用户通过MCP协议将自然语言指令转化为三维建模操作,项目开源一周便获得了5.4k stars。

  • SaaS服务商拥抱MCP Server

随着MCP的普及,SaaS服务商可以通过集成MCP Server触达新的市场和行业机会,而MCP协议的Stdio和SSE标准要求服务和数据供应商提供API访问,而云上托管将是最优选择。

  • 企业级 MCP Server 要求安全合规和弹性伸缩

MCP Server 将服务/数据对接给大模型,如果不限制大模型的数据权限范围和敏感数据过滤,将对企业产生安全合规风险,云上托管提供权限管控、操作审计、用户隐私保护等内置安全工具,大幅减少安全风险暴露面,合规成本低。同时 MCP Server 的爆火,对服务商是巨大的机会,服务商将面临着用户量和模型调用量的突增,云上托管如函数计算具备免运维、自动弹性、自动容灾的优势,确保服务体验的同时实现降本增效。

云上托管 MCP Server 核心痛点

  • 传统托管效率低

从 MCP 架构的描述中可以看到,MCP Server 作为 AI 大模型和企业服务的中间层,通过购买传统云资源部署效率低下,其代码通常相对轻量,开发者需要快速部署,快速测试仅仅可能是一条NPX命令。 "MCP Servers: Lightweight programs that each expose specific capabilities through the standardized Model Context Protocol"。

  • 业务规模不确定

作为原有 Function Calling 的替代者,工具调用请求规模具有显著的不确定性,传统云资源托管需要长期持有资源,资源供给无法实现按业务流量进行灵活的动态适配。

  • 定制扩展流程复杂

MCP Server 作为AI和企业服务能力的中间层,其逻辑覆盖简单路由到复杂计算,随着业务场景的丰富会变得越发复杂,务必在选择云上托管的时候,也要考虑后续业务的开发和维护效率。 务必要求开发层面需要更灵活的定制能力,实现快速变更,快速上线,灵活的版本和流量管理。

  • 数据访问网络配置复杂

传统MCP Server依赖于本地化部署实现数据安全,随着云端部署的普遍化,云端 MCP Server 不仅需要能够实时安全的访问企业私有数据,也需要适配复杂的业务环境,在Internet和Intranet网络之间进行互通,这就要求能够快速的互联网公共服务和企业云上 VPC 快速打通,提供安全灵活的执行环境。

函数计算成为云上托管 MCP Server 的最简方式

社区积极的推动 MCP 协议演进,推动 Steamable HTTP transport 技术代替原有 HTTP+SSE 的通信方式,原有的 MCP 传输方式就像是你和客服通话时必须一直保持在线(SSE 需要长连接),而新的方式更像是你随时可以发消息,然后等回复(普通 HTTP 请求,但可以流式传输)。这种形式与 Serverless 算力无状态模式更加契合,协议层演进将更有利于云上Serverless算力的价值放大,随着AI模型复杂度和数据规模持续增长,Serverless与MCP Server的结合将成为趋势。

github.com/modelcontex…

image.png

函数计算作为云上Serverless 算力的典型代表,其凭借在开发效率,按需付费,极致弹性等产品能力直击云上 MCP Server 托管的核心痛点,为企业MCP Server 提供高效,灵活,匹配业务规模的托管能力。

  1. 成本效益最大化
    • 按需付费,避免资源浪费:Serverless按实际计算资源消耗计费,而非固定服务器租赁费用,尤其适合AI训练和推理任务中常见的波动性负载。
    • 消除闲置成本:AI模型训练通常需要突发性算力,Serverless能自动分配资源,避免传统模式下预留资源导致的服务器空置问题。
  2. 弹性扩展与资源优化
    • 动态资源分配:将MCP Server 托管在函数计算上,基于Serverless架构,可实时响应AI任务需求,自动扩展CPU/GPU,确保算力高并发处理能力。
    • 多模型协作支持:支持多个AI项目并行运行,资源按优先级动态调度,提升整体算力利用率。
  3. 简化运维与加速开发
    • 无服务器管理:开发者无需关注服务器配置、补丁更新或集群管理,专注算法优化和迭代MCP Server 内部逻辑和工具丰富度。
    • 开箱即用的工具链:函数计算提供了完善的工具链能力,基于开源 Serverless Devs开源工具实现本地快速部署。
  4. 更灵活的MCP协议适配
    • 当前函数计算提供单实例多并发能力,扩展对存量SSE协议的适配,基于社区提供的MCP Proxy方案能够快速将存量本地MCP Server托管到云端,方便业务平台的测试开发。
    • 提供基于WebSocket 的MCP协议适配参考实现,支持单实例单并发和单实例多并发能力支持,提升协议适配和场景适配;同时团队紧跟社区 Streamable HTTP 方案,敬请期待!

体验:一键部署热门 MCP Server

依赖 Serverless 应用开发平台 CAP,我们能够快速实现开源 MCP Server 一键托管,假如您搭建的 AI Agent 中需要加入导航服务,您可能会需要高德社区提供的 MCP Server ,接下来我们将以开源项目 amap-maps-mcp-server 为例演示如何一键部署 MCP Server 到函数计算FC上。

第一步: 模版部署

点击 cap.console.aliyun.com/create-proj… 进入CAP控制台。填入从高德开发者申请的 Token(立刻申请完成),可以在这里申请

image.png

第二步: 测试 MCP Server 提供的工具能力

部署成功之后,通过触发器页面,拿到测试URL可对当前MCP Server进行测试。如果希望将部署的MCP Server 用于生产,建议使用自定义域名代替测试URL。

image.png

测试步骤一:本地终端运行命令: npx @modelcontextprotocol/inspector

image.png

测试步骤二:浏览器中打开本地提供的测试地址“http://localhost:5173/#tools ”进行测试,在URL表单中填入上面获取的URL,添加/sse 后缀填入URL表单中,点击Connect会看到开源 MCP Server提供的Tools列表,可以点击置顶Tool进行交互验证。

image.png

如果您对于产品有更多建议或者对 MCP server 云端托管有更多想法可以加入钉钉群(群号:64970014484)与我们取得联系。

更多开源 MCP Server一键部署

MCP 开源地址 编程语言 一键部署 Server 类型
github.com/baidu-maps/… Node cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Node cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Node cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Python cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Node cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Python cap.console.aliyun.com/create-proj… mcp-proxy
github.com/devsapp/ama… Node cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Node cap.console.aliyun.com/create-proj… sse
github.com/modelcontex… Node cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Node cap.console.aliyun.com/create-proj… mcp-proxy
github.com/modelcontex… Node cap.console.aliyun.com/template-de… sse
github.com/modelcontex… Node cap.console.aliyun.com/create-proj… mcp-proxy

更多内容关注 Serverless 微信公众号(ID:serverlessdevs),汇集 Serverless 技术最全内容,定期举办 Serverless 活动、直播,用户最佳实践。

昨天以前首页

前端学AI:LangGraph学习-基础概念

作者 牛奶
2025年4月1日 18:27

前端学AI:LangGraph学习-基础概念

本文主要介绍下LangGraph 的一些基础概念,包括定义、应用场景、核心概念和主要功能,让读者了解什么是LangGraph,以及它能做什么,在哪些场景用,使用哪些主要功能等。

供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

最近在学习 LangGraph,顺便分享下。

什么是LangGraph

LangGraphLangChain 的扩展库,专门用于构建和管理复杂的状态机工作流程。提供声明式的工作流程定义方式,以及支持循环计算状态管理,使得复杂业务逻辑的实现变得更加简洁和高效。

在 LangChain 的基础上扩展了功能,LangGraph 增加了对异步操作错误处理并发任务的支持。它的状态化特性使得系统能够记住之前的交互用户偏好,从而提供更个性化的响应。

同时,其支持循环计算的能力突破了传统工作流的限制,使开发者能够更自然地模拟现实世界中的决策过程。

LangGraph应用场景

LangGraph 凭借其支持循环计算状态管理的特性,在多种复杂场景中表现出色。

交互式代理(Interactive Agents)

它的状态化特性使其非常适合构建能够进行多轮对话的智能代理。可以通过状态图(State Graph)跟踪对话历史,并根据用户输入动态调整后续的响应逻辑。循环计算功能还允许代理在需要时反复询问澄清问题,确保对话自然流畅。这种能力特别适用于需要上下文感知的场景。

例如,聊天机器人可以利用 LangGraph 记住用户的请求(如“我的订单状态”),并在多轮对话中逐步收集必要信息(订单号、用户信息等),最终提供准确的答复。如果用户的问题不完整,机器人可以通过循环逻辑返回到澄清步骤,直到满足条件。

复杂决策系统(Complex Decision-Making Systems)

LangGraph 在复杂决策系统中表现出色,它能够通过条件边(Conditional Edges)和状态管理处理多变量标准,并根据实时数据更新决策路径

例如,在自动驾驶场景中,可以用来构建决策系统,处理诸如“是否变道”“是否减速”等复杂判断。系统通过状态图跟踪车辆状态(如速度、周围环境),并根据传感器数据动态选择行动。如果条件未满足(如前方有障碍物),它可以循环回到评估步骤,直到安全执行决策。

迭代处理模型(Iterative Processing Models)

它的循环计算功能非常适合需要通过多次迭代改进结果的任务。可以在每次循环中根据反馈或评估标准调整输出,直到满足预设条件。

例如,内容生成系统可以用来生成文章或创意文本。系统首先生成初稿,然后通过循环逻辑根据用户反馈(如“更简洁一点”)或内部评估(如语法检查)进行优化。每次迭代都会更新状态,最终输出符合要求的内容。

LangGraph核心概念

概念 定义 主要功能 详细解释
节点(Nodes) LangGraph 中的计算单元,通常是同步或异步函数,负责执行工作流中的特定逻辑 读取和更新状态通道,执行任务如调用 LLM、处理输入或与外部工具交互。 节点通过与状态通道交互来工作,通道是键值对,存储当前状态。节点可以覆盖或追加通道值,具体取决于归约函数(reducer)。支持自定义逻辑和工具集成,适合多代理系统。
边(Edges) 定义节点之间连接的路径,控制工作流的执行流向 控制流程,可以是静态的(固定下一个节点)或条件性的(动态决策)。 条件边通过函数评估状态,决定下一步节点或终止图。支持分支逻辑和人类干预。
状态图(State Graph) 由节点和边组成的有向图,管理复杂、状态化的工作流 支持循环计算状态管理,通过通道归约函数维护状态。 状态通过通道管理,每个通道有默认值和归约函数。支持持久化机制(如按线程存储),允许长时间运行的工作流和中断恢复。调试工具如 LangGraph Studio 增强可视化。

LangGraph主要功能

循环计算(Cyclic Computations)

循环计算是 LangGraph 的一大亮点,能让任务“转圈”跑,不像传统的工作流程(比如有向无环图,简称 DAG)那样只能直线走到底。

传统的 DAG 就像一条单行道,走到头就停了,而 LangGraph 可以让任务回头再来一遍。

这对模拟“像人一样的智能”(类代理行为)特别有用,比如一个智能助手在找答案时,如果第一次没找对,可以再试一次,调整方法,直到成功。

状态化执行(Stateful Execution)

状态化执行就是 LangGraph 能“记住东西”。它使用 MemorySaver 的工具,把任务的状态(比如对话记录或用户选择)保存下来,像给每个任务开个小笔记本,随时翻看和更新。

这让程序变得更聪明,能根据之前的记录调整下一步,比如一个聊天机器人记住你说过的话,下次回答更贴心。

条件逻辑(Conditional Logic)

条件逻辑是 LangGraph 的“决策大脑”,通过条件边,根据当前情况决定下一步怎么走。

比如,一个聊天机器人发现你没回答完整,它就回头再问你,而不是傻乎乎地继续往下走。

这种灵活的决策方式特别适合需要随机应变的场景,比如自动驾驶根据路况决定刹车还是转弯。

推荐资料

专栏系列

手把手教你在浏览器中处理流式传输(Event Stream/SSE)

作者 桜吹雪
2025年4月1日 17:15

前文:手把手教你在浏览器和RUST中处理流式传输 提到如何简单的处理流式输出,但是后来发现这个写法有bug,下面讲解一下更好的写法

顺便补充一下,上一篇文章提到的IterableReadableStream来自@langchain/core,你可以这样导入使用:

import { IterableReadableStream } from '@langchain/core/utils/stream'

处理Event Stream

除了上一章的ndjson以外,最常用就是Event Stream了,包括OpenAi等一众ai服务提供商都会提供sse接口,并且以Event Stream的格式进行输出,先来看看ai是怎么理解Event StreamSSE的:

Server-Sent Events (SSE) ,一种基于 HTTP 的轻量协议,允许服务器向客户端推送实时数据流。

SSE 格式规范

  • 数据通过 HTTP 流式传输,内容类型为 text/event-stream

  • 每条事件由字段组成,用换行符分隔。字段包括:

    • data: 事件的具体内容(必填)。
    • event: 自定义事件类型(可选)。
    • id: 事件唯一标识符(可选)。
    • retry: 重连时间(毫秒,可选)。

示例

event: status_update
data: {"user": "Alice", "status": "online"}

id: 12345
data: This is a message.

retry: 3000

那再来看看ai输出的结果:

image.png

很标准的text/event-stream格式

使用langchainjs处理

你以为我要像上一篇一样开始手搓处理代码了吗,no no no,我们还是使用langchainjs进行处理,原因后面会提到。

这里推荐一个fetch封装工具:ofetch,一个类似axios的库,作用大家应该都懂了吧,这里我拿火山的接口来演示:

// vite.config.js
export default defineConfig({
  base: "/",
  server: {
    proxy: {
      "/huoshan": {
        changeOrigin: true,
        ws: true,
        secure: false,
        target: "https://ark.cn-beijing.volces.com",
        rewrite: (path) => path.replace(/^\/huoshan/, ""),
      },
    },
  },
});

// vue.config.js
module.export = {
  devServer: {
    compress: false, // 重点!!!不关闭则有可能导致无法正常流式返回
    proxy: {
      '/huoshan': {
        target: 'https://ark.cn-beijing.volces.com', // 代理
        changeOrigin: true,
        ws: true,
        secure: false,
        pathRewrite: {
          '^/huoshan': '',
        },
      },
    }
  }
}

如果是webpack的话,一定要关闭devServercompress,不然会导致整个请求结束才返回,这样就不是流式输出了。

// request.js

import { ofetch } from "ofetch";

export const fetchRequest = ofetch.create({
  baseURL: '/huoshan',
  timeout: 60000,
  onRequest({ options }) {
    options.headers.set('Authorization', 'Bearer xxxxx') // 替换火山api的key
  },
})
import { fetchRequest } from "./request";
import { convertEventStreamToIterableReadableDataStream } from "@langchain/core/utils/event_source_parse";

async function test() {
  const res = await fetchRequest("/api/v3/chat/completions", {
    responseType: "stream",
    method: "post",
    body: {
      model: "deepseek-v3-250324",
      messages: [
        {
          role: "user",
          content: "你是谁?",
        },
      ],
      stream: true,
    },
  });
  const stream = convertEventStreamToIterableReadableDataStream(res);
  for await (const chunk of stream) {
    console.log(chunk);
  }
}
test()

image.png

返回正常,不过要注意,结尾有个[DONE],所以不能无脑反序列化,

for await (const chunk of stream) {
  if (chunk !== '[DONE]') {
    console.log(JSON.parse(chunk))
  }
}

这样就拿到每个chunk了,当然你可以将test方法改成生成器,然后for里面yield JSON.parse(chunk)

为什么要用langchainjs封装好的方法处理

既然大家都知道流式输出是一个一个chunk的方式返回,那么是不是有可能一行的文本,拆分成两个chunk(在js看来是ArrayBuffer)?而一个utf8字符是定长的,可能是1-3字节,那是不是有可能在某个字符的时候,其中一部分字节拆分到一个chunk,然后剩下部分字节拆分到下一个chunk?

这样就会导致你在decode的时候发生报错,无法正常decode成文字,所以langchainjs的方法考虑到这个情况:

image.png

代码在:github.com/langchain-a…

其他关注点

使用代理时需要注意

上面的webpack配置已经讲解了一下devServer应该怎么配置才能流式输出。还有就是使用nginx代理的时候也需要修改一下配置:

server {
listen 80;
location /huoshan/ {
                # http1.1才支持长连接
proxy_http_version 1.1;
# 关闭代理缓冲
proxy_buffering off;
# 设置代理缓冲区大小
proxy_buffer_size 10k;
# 设置代理缓冲区数量和大小
proxy_buffers 4 10k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass https://ark.cn-beijing.volces.com/;
}


}

其实就是关闭一些代理缓冲,以及设置一下缓冲区,为什么要这样设置,这里有请懂nginx配置的大佬细说一下😜

GTC 2025 见闻

作者 bang
2025年3月28日 21:50

参加了 NVidia GTC (GPU Technology Conference),由于英伟达的地位,这会也已经成了 AI 开发者最大的交流会,很多公司和业内人士都会过来分享、交流,大概写下会议中相关见闻感受。

Keynote

老黄没提词器洋洋洒洒讲了两个多小时,出了小状况还会开个小玩笑,大佬范很足,也满满的理工男既视感,非常多的数字和未经包装的细节,不过感觉会讲得有些啰嗦。

总的来说,核心论证的是世界对 GPU 诉求会越来越大,而 NVidia 在 GPU 这个领域会持续遥遥领先

GPU诉求

计算机的核心从 CPU 转向 GPU,上个时代依靠程序员写代码指挥 CPU 执行指令解决问题,构成了现在庞大的 IT 产业,程序员是中心。现在的时代逐渐转变,GPU 生产的 token 逐渐能解决越来越多的问题,能思考,能生成代码指挥 CPU 去执行解决问题,计算的核心一定会转向 GPU,世界对 GPU 的需求只会越来越高。

给 AI 分了四个阶段,Perception AI → Generative AI → Agentic AI → Physical AI,不是很认同,Agentic 和 Physical 都是 Generative AI 的延续,不过无所谓,可以看到 Agentic 这个概念实在是火爆。

Scaling Law 没有停止,Agentic AI 需要深度思考,深度思考有新的 Test-time Scaling Law,越多的 token 输出效果越好需,要多轮理解和工具调用对 token 的消耗更是指数级上涨。

Physical AI 要更好地理解现实世界,声音/视觉/触感,都会比纯文本思考对 token 消耗的诉求更高,像 2G 时代看文字新闻,3G 4G 图片,5G 视频一样。

这两个发展中的领域对 GPU 的需求只会越来越高,Deepseek 做的优化也不足以影响这个需求的增长,这个市场不容质疑。

NVidia 优势

GPU 需求量是高,但未来大家一定会买 NVidia 卡吗?当然。NVidia 这一代 blackwell 算力是 hopper 的 68 倍,下一代计划明年推出的 Rubin 算力是 hopper 的900 倍,一年一迭代,远比摩尔定律快的速度,还做了大量的大规模部署的优化,省电、稳定,号称买越多,省越多,赚越多,竞对看起来会很难追上。这些论述还是挺能让人 buyin 的。

Agentic AI

Agent 的相关 session 有接近 200 个,Agent 集合了几个元素:

  1. 概念火,一些涉及 Workflow/RAG 什么的 AI 应用都统一称为 Agent 了,GenAI 在各行业的落地都可以冠以 Agent 的名义,跟以前 H5 那样,不纠结于具体定义,只要有一个统一称呼。
  2. 人群广,Agent 目前主要是在上层的工程架构上,大量的工程师都能理解、参与讨论、建设,不像基础模型训练,多数人难以参与。
  3. 应用广,非研发也能大概听得懂,涵盖了 AI 在各行业的应用这个课题,各行业都会有兴趣了解 Agent 是什么,自己业务上能怎么用。

所以 Agent 相关的 session 大部分都很热门。听完一些的感受:

  1. 多数做企业服务、云的公司都在卷 Agent 的基建和解决方案,像基础设施公司 Fireworks AI、Nebius,数据库公司 Couchbase、datastax,企业服务公司 serviceNow、Dropbox,新兴公司 huggingface、langchain、langflow 等,都来分享推广在 Agent 这事上能提供的能力和服务。
  2. Agent 相关的建设都在刚起步,基本都是在分享概念、工程问题的优化和应用方案,没看到有涉及模型训练去优化 Agent 效果上限的相关分享。Agent 的一些关键课题上一篇文章有提到,基本差不太多。
  3. 也没有讨论 Agent 在工程和模型上的界限,后续端到端的模型进步,能吃掉多少 Agent 能做的事?这两天 4o 的图生成出来后,预计后面才会有更多的讨论。

NVidia AI 基础服务

NVidia 作为领头羊,是希望自己能覆盖 AI 全链路基础设施的,大力在 AI 的每一层都提供了相关框架、服务、能力,这次会议上也有非常多的分享和推广。

其中跟 AI 应用 / Agent 相关的几个基建:

  1. BluePrint:应用蓝图。给了很多 AI 应用场景的 example 工作流(也称为 Agent),例如 PDF 转博客、数字人应用等,提供工作流架构、数据集、源码,可定制,供开发者快速参考和部署。
  2. NIM(NVIDIA Inference Microservices**)**:模型推理。把模型推理封装在 Docker 容器里,可以直接快速部署,对外提供标准化API。也封装了模型在不同 GPU 型号下的优化,提升性能效率。
  3. NeMo(Neural Modules):模型训练。提供了相关工具用于构建、定制、训练 AI 模型,训练后的模型可以通过 NIM 部署。
  4. AgentIQ:开源 Agent 开发套件,支持组合链接不同框架创建的 Agent,提供性能 profiler、评估、UI 界面等工具。

这些基建的声量比较低,国内没怎么见到,不确定海外使用情况怎样。

多个 session 都在推广 NVidia 的 Video Search and Summarization Agent,串联从视频的获取→分割→VLM识别、CV物体识别和跟踪→数据处理存储和RAG召回→用户对话 整个流程,做到可以对视频提供实时分析和报警,也可以自然语言交互查询视频内容,边缘部署,适合用于监控,算是用 NVidia 技术栈做 AI 应用的一个标杆范例。

AIGC

关注了下视频 AIGC 相关的几个 Session

  1. 在好莱坞干了几十年的视觉效果的 Ed Ulbrich 开了个公司 Metaphysic,以前的电影特效制作成本巨大,对人的处理还很难跨过恐怖谷,而基于 AI 技术做特效,用完全不同的技术栈,效果好成本低,是一种颠覆。metaphysic 给娱乐行业提供人脸替换、数字人的服务,看起来是用的 GAN,在人物换脸技术上,GAN 还是更能做到稳定和实时,特别是实时这个点,基于 diffusion 很难做到。基于市场需求,利用已有的不同技术(甚至是上一代技术)深入解决问题,是有空间的。
  2. PixVerse Co-Founder 在一次对话中聊到,视频实时生成的能力差不多要 ready 了,目前 5 秒的视频可以做到5-10秒推理完成,可能会解锁新的人跟视频的交互方式。不确定质量怎样,质量达到一个阈值,以前设想的很多类似 自定义剧情走向 的新玩法新交互有很大空间。
  3. Adobe 和 OpenSora 都来分享了视频生成模型的训练和推理的方案和优化,鉴于已经不是SOTA模型,可参考性不高。TCL 分享了AI电影制作,很惊讶这公司竟然在做这个,更多的是在做链路串联,而不是端到端的视频模型。

其他

  1. OpenAI 只来了两个人给 blackwell 架构站站台,Anthropic 一个人也没来,从这上看,这行业最领先的技术还是很 close,毕竟是核心竞争力,而且很容易被复刻,不像上个时代,大规模并发架构等技术,更重的是实践中解决具体问题,大方案分享了问题不大。(所以 DeepSeek 开源最领先的技术带来的冲击才会那么大。)
  2. DeepSeek 就是 Reasoning Model 的代名词,开源模型的顶流,出镜率极高,老黄的 keynote、各种演讲里都有它的身影,而 llama 通常是作为上一代开源模型与它做对比,只要是提供开源模型部署服务的公司(HuggingFace/Fireworks等),分享里都会对 DeepSeek 极度推崇。
  3. 遇到不少学生来参加,有的来找方向,看看业界前沿在做什么,做学术交流,找合作机会,这个会是挺合适的。清华、中科大、SJSU。最大的问题是实验室没有足够的卡,这领域是必须校企合作,实验室才进行得下去了。
  4. 使用 Nvidia Jetson 做边缘计算也是预期后续空间比较大的方向,设备端部署模型,可以提升实时性和隐私性,多数分享是用在具身智能上,还有一个分享的场景是在货架上实时分析用户行为,更精准推送广告。
  5. 机器人、自动驾驶的 session 也很多,数字孪生是提得比较多的(用 AI 生成仿真环境,用于机器人训练),但现场没看到什么能震惊人的机器人,包括老黄演讲时演示的类 wall-e 机器人,惊艳不够,这一行感觉还早。

总体感受,眼花缭乱,人潮纷杂,在开拓视野以外,大会更多是一个社交场所,推广产品/技术/服务,促进合作,这类大会需要的是多创造一些面对面交流的机会。

花絮

  1. 现场有限量的原价 5080、5090,知道时已经不可能排队买到。
  2. 跟七年前参加 WWDC 在同一个地方,估计一直还是同一个承办公司,午餐还是那么难吃。
  3. 参观 NVidia 工区,老黄作为华裔也是信风水的,新办公楼会模拟依山傍水的设计,风水好。NVidia 搞渲染出身,渲染里三角形是最基本单元,所以办公楼都是三角形元素。办公环境很宽敞,但没啥人,总部居家办公没有限制,很多都不来公司。

体验完豆包新版深度思考,我发现 AI 终于学会像人类一样「边想边搜」

作者 莫崇宇
2025年3月27日 22:01

你有没有发现,现在的 AI 搜索真的很懂摸鱼?

当 ChatGPT、Perplexity 等工具相继问世,都说 AI 将彻底颠覆搜索领域,但现实情况却是,当我随手扔给它一个复杂问题时,一顿操作猛如虎,哐哐一顿搜索几百个网页,搜索结果却平平无奇。

仔细一看,要么堆砌了一堆零散的信息,要么抓不住重点,感觉就像是把一堆资料硬塞给我,自己却没怎么动脑子,像极了敷衍了事的职场人。

不过,这也不能全怪 AI。毕竟换位思考一下,即便是人类,带着问题去查资料时,也很容易被信息洪流冲昏头脑。不少 AI 产品开始对此进行优化,比如 OpenAI 和 Grok 在推理模式基础上又推出了 Deep Research/DeepSearch 模式。

国内厂商里,字节也刚刚给出了新的解决方案,对豆包的深度思考功能进行了升级。正在测试的豆包新版深度思考的一大亮点便是免费支持「边想边搜」,现在下载最新版豆包 APP,或在 PC 及 Web 版豆包中即可体验该功能。

APPSO 也第一时间进行了深度体验。

简单来说,用户无需单独开启搜索功能,只需打开深度思考模式,AI 能在推理过程中灵活调用搜索工具,进行多轮动态搜索。

从「先搜后想」到「边想边搜」,AI 终于学会了如何像人类一样搜索问题。

DeepSearch+深度思考,豆包这个新功能不止让你少问几步

生成式 AI 发展两年了,颠覆搜索了吗?

早期的 AI 搜索工具虽然不怎么做互联网的搬运工,但模式上还是传统的「先搜后想」的套路——先把网上的信息抓一堆,再根据这些信息组织答案。

豆包新版深度思考则不一样,它结合了深度思考能力,把搜索和推理捆绑在一起,基于每一步的思考结果进行多次搜索,能让回答更有逻辑、更贴近需求。听起来挺玄乎,我们也用几个问题来实际体验一下。

先来个贴近生活的投资问题,「如果我从今年 1 月开始同时投资小米股票和英伟达股票,截至 3 月 24 日收盘哪个收益更高?」豆包的回答让我有点意外。

APPSO 拆解了豆包新版深度思考的思考过程,我们发现它的搜索逻辑有了明显不同:

1. 先进行问题分析和框架设定

2. 获取初步信息建立概念

3. 深入挖掘具体数据点

4. 遇到不确定性时进行额外搜索

5. 基于现有信息进行合理推断,并给出结论

之前 AI 可能会直接给出两支股票的涨跌百分比,然后就直接得出结论,但豆包新版深度思考则进行了多轮思考,进行问题分析和框架设定(时间段-股价表现-调用搜索工具)。

一旦有了较为妥当的思路,它便会继续搜索,比如在互联网上找到了 14 篇参考资料,这个过程仅仅是为了获取一个笼统却清晰的概念,方便进一步边思考边有针对性地搜索。

由于缺乏 1 月 1 日(休市)的准确收盘价,以及英伟达的数据存在不确定性,它需要再次搜索来确认这两个关键数据点,最后基于现有信息进行合理推断。

最后的结果,也不只是给出投资收益的对比,还对股价波动因素进行分析,并提示了未来的风险,甚至整理成了表格,考虑得颇为周全。

洞察到了我想问但没说出来的问题,把需要「追问」的细节提前融入答案之中,妥妥一个醒目的投资顾问。


最近我计划去新加坡旅游,想知道有没有最优的往返机票方案。

普通 AI 搜索引擎可能一股脑儿搜几百个网页经验帖,然后汇总交差,但豆包新版深度思考则有所不同,它会带着问题思考,拆解几个关键点——出发地、时间、预算等,然后逐步深入分析,形成一个「思考-检索-继续思考」的良性循环。

而这恰恰也说明了豆包的新版深度思考倾向于「思考驱动」而非「搜索驱动」。

换句话说,以前需要照顾 AI 的能力,把大问题拆分为几个小问题,一步步问清楚;可现在完全不用,直接丢出你的需求,剩下的交给豆包就行。

或许正因如此,它的整体响应速度体感上并不慢,体验相当流畅。

有个很现实的问题,没时间看国足比赛怎么办,别急,这时候就可以请出豆包新版深度思考来救场。把你想知道的具体内容告诉豆包,比如比赛结果、关键时刻、球员表现或者规则积分,它就能化身速通大师,省时又高效。

当然,如果不开启深度思考功能,我们会发现,虽然回答依然快刀斩乱麻,效率没得说,但质量明显就差了一截。不仅缺乏更清晰的分点罗列,连逻辑层次感都显得单薄,甚至引用的资料信息更少。

这么一对比,像人类一样思考的重要性就凸显出来了。有了深度思考的加持,它能把答案打磨得更精致、更贴心,条理清晰不说,还能塞满干货,让你读起来既舒服又有收获。

接下来,我们用更贴近个人需求的决策场景来考验它。

对于 iPhone 16e,我们给出的观点是,这是一台酱香型手机,越晚入手越香,那它和 iPhone 16 比,哪个更有性价比,以及如果用腻了,又该换哪款备用机?

就像 Grok DeepSearch 标配的图表一样,豆包新版深度思考也提供了清晰的参数对比,屏幕、芯片、摄像头一目了然,甚至还贴心地准备了数据迁移建议,这贴心程度值得点赞。

逻辑性是豆包新版深度思考回答的最大特点。

针对 Android 备用机推荐问题,它不会一股脑儿抛给你一堆机型名字,而是通过「边想边搜」的迭代循环,针对你可能会用上的使用场景,再一步步搜索、推理,最后奉上一份既有逻辑又实用的推荐清单。

当然,扒蛛丝马迹这种细活儿,还是得交给 AI。

「悟空在第十四回中打死的六个盗贼,分别叫什么名字?该如何理解作者这一情节的安排?」豆包新版深度思考的回答不仅列出具体名字,还融入了佛学和心理学视角,分析得头头是道,时不时冒出几句金句,颇有亮点。

李白、杜甫和白居易是唐代诗人的标志性符号,那他们三者之间是否存在交集?

对于这个问题,在豆包新版深度思考的理解中,这种交集并不局限于现实生活的人际往来,还延伸到了更广阔的文学脉络以及彼此风格与创作上的相互影响。

最后所引用清代赵翼的点评「李杜诗篇万口传,至今已觉不新鲜;江山代有才人出,各领风骚数百年」,恰到好处,为整个分析增添了历史厚重感,也让人读后回味无穷。

从「先搜后想」到「边想边搜」,搜索的未来长什么样?

技术未来学家、Google 工程总监雷·库兹韦尔(Ray Kurzweil),曾在《奇点临近》一书中预测,未来的搜索将像人一样思考,而不是像机器一样索引。如今,这一预言正在成为现实。

之前的 AI 搜索,其「先搜后想」的模式是一个简单粗暴的线性过程:

「输入问题 → 调用搜索工具获取数据 → 基于数据进行推理 → 输出答案。」

这种方法的短板显而易见,非常依赖关键词匹配和网页索引技术,导致信息「广而不深」。

基于深度思考和 DeepSearch 的 AI 搜索已经大大解决了这个问题,AI 能理解自然语言中的复杂语义,比如问它「明天广州适合穿什么衣服?」就可以分析天气数据、时尚趋势、个人偏好等隐藏需求,实现多维信息关联。

而豆包新版深度思考与多轮搜索相结合的模式,进一步补足了深度思考和 DeepSearch 在处理复杂、模糊及动态信息需求时尚存的几块短板。

  1.  一轮搜索无法解决的复杂问题: 更好应对需要多轮信息整合、动态调整策略的复杂任务。在思考过程中多次搜索,让信息持续更新和补充,给出更全面、准确和深入的回答。
  2.  模糊查询与信息关联: 对于一些描述模糊、信息不完整的问题,「边想边搜」让模型在推理过程中不断搜索和验证,逐步缩小范围,找到相关信息
  3. 动态规划与多步骤任务: 在处理需要长期规划或多个步骤的任务时,「边想边搜」能够在每一步骤中进行信息校验和调整,提高了执行任务的成功率。

豆包新版深度思考「边想边搜」的执行路径,让我不禁想起最近常被提及的 Agent。「互联网之父」Berners-Lee 早在多年前就提出:

真正的智能体,就是在每个具体场景中,都能自动完成用户心里想做却没明确说出来的事情。

虽然豆包新版深度思考和 Agent 还有些区别,但某种程度上却是 Agent 工程化思路在搜索上的应用。Agent 自主决策和动态调整,将任务高度自动化,大大减少额外的数据预处理和人为干预。

说白了,就是让 AI 像一个聪明的助手,自己去网上找答案,它能自己动手,自己动脑,找到我们要的东西。用户不需要像喂饭一样把问题拆得细碎,才能得到满意回答。

由此我们也可以推理出 AI 时代理想的搜索过程:

  • 1. 接收并分析用户问题
    用户提出问题后,AI 会先分析问题的内容,拆解其核心需求,并尝试推测用户的真实意图。
  • 2. 自主选择搜索策略
    根据问题的性质,决定是用通用搜索还是直接调用特定专业数据源。它能记住常用工具和 API 的调用方式,直接利用现有网络资源(如站点地图或结构化数据)进行搜索,而非依赖预设流程。
  • 3. 多步骤动态搜索
    搜索不是一次性完成,而是分成多个连续步骤。先发起初步搜索,查看结果后,可能调整关键词、浏览网页深入挖掘,或转向其他数据源,模拟人类在网上探索的过程。
  • 4. 实时优化搜索路径
    在搜索过程中不断学习和判断。如果发现当前方向无效,它会自主放弃并尝试更有效的路径,如同经验丰富的专业人士那样灵活应变。
  • 5. 整合信息并生成结果
    搜集到足够信息后,它还会通过推理将零散内容重新组合,将结果整理成逻辑清晰的总结,确保输出符合用户需求。
  • 6. 记录决策过程
    每一步决策和推理都会留下清晰的记录,增强结果的可解释性,让用户能追溯其搜索逻辑。

如同媒介理论家保罗·莱文森所言,技术进化是人类认知结构的外延。每个时代技术的核心驱动力是信息处理能力的提升,当 AI 以越来越接近人类的思维方式处理信息,重塑的将不仅仅是我们对搜索的习惯。

作者:李超凡、莫崇宇

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


DesignToCode的AI工具初体验——Codia篇

作者 Viva49641
2025年3月27日 14:14

Codia AI Figma to code: Figma to full-stack Web and Mobile apps | Figma

使用步骤

步骤非常简单,和官方描述一致,只需要三步就能将设计稿转换成代码,即打开插件 → 选中图层 → 生成代码。

产物分析

站在前端开发的视角,对产物代码的初始效果以及二次修改的维护性进行分析。

优点官方已经夸得很多了,我就不多说了,比如:

  1. 快速转换设计稿
    • 高效生成代码:Codia 能够快速将设计稿(如 Figma 设计)转换为功能性代码,大大节省了开发人员从设计到开发的转换时间。
    • 支持多种格式:不仅支持 Figma 设计稿,还能将截图、PDF 文件等转换为可编辑的 Figma 设计,进一步扩展了其应用场景。
    • 响应式设计支持:通过简单提示,Codia 可以在 Figma 中创建响应式网站 UI,帮助设计师快速构建适应不同设备的设计。
  2. 提升设计效率
    • 图片转矢量图:能够将 PNG 或 JPG 图片转换为清晰的 SVG 矢量图,提高设计的灵活性和可扩展性。
    • PDF 转 Figma 设计:可以将 PDF 文件迅速准确地转换为可编辑的 Figma 设计,方便设计师直接在 Figma 中进行修改和优化。
  3. 易于使用
    • 操作简单:用户只需上传设计稿或截图,Codia 的 AI 算法即可自动完成转换,无需复杂的操作流程。
    • 集成友好:作为 Figma 插件,Codia 能够无缝集成到设计师的现有工作流程中,无需额外的学习成本。

tsx 部分

初见 Codia,给我的第一印象就是“抽象死板的画图仔”,满满的古早味。它就像是一个只会堆砌 divspan 的工具,完全缺乏结构性设计,更别提支持动态数据了。不过,它也有自己的优势:最让人头疼的样式类命名已经完全准备好了,再也不用担心自己只会起 “title”“subTitle” 这种简单名字了。而且,div 的嵌套设计相对来说还算合理。

对于那些经常需要改版、不需要细节维护的静态页面或者 demo 演示来说,Codia 简直就是古希腊掌管时间的神,能大大节省时间。但对于那些需要长期迭代细节、动态数据又很多的项目,就还是慎重考虑吧。毕竟,这比在同事写的“屎山”代码上继续添砖加瓦的难度还要高得多。

css 部分

初看之下,Codia 使用了flex布局,这确实让人眼前一亮。但仔细研究后发现,它仅在一些小图块(如按钮、文字区域)上使用了flex布局,而在整体大布局上,依然依赖绝对定位和 margin居中定位。这种布局方式虽然能用,但显得有些过时,适应性也不够强。如果后续需要进行二次修改,可能会相当麻烦。

总结

Codia 似乎更像是一款给设计师打破“技术”和“设计”边界的一款产品,更倾向于辅助设计师将设计稿快速转换为可编辑的设计资源或基础代码。然而,要使其生成的代码完全适合生产环境,还需要在代码优化、动态支持和维护性等方面进行改进。对于开发者来说,Codia 可以作为一个起点,但需要根据项目的具体需求进行进一步的开发和优化。

核心的竞争力:在 Figma 中集成。

Codia 作为 Figma 插件,能够无缝集成到 Figma 的生态系统中。这种紧密的集成使得设计师可以在他们熟悉的设计环境中直接使用 Codia 的功能,无需切换工具或平台。这种无缝衔接不仅提高了工作效率,还减少了学习成本。

在开发视角下的核心短板:代码结构落后,缺乏模块化设计和动态数据注入。

这些问题使得开发者在使用 Codia 生成的代码时,往往需要投入额外的时间与精力进行优化与调整,以满足项目的实际需求。

AI 生成的 Markdown 无法直接分享怎么办?写一个 Markdown to 在线 HTML 链接转换器

作者 _jiang
2025年3月27日 11:36

本项目的初衷是为了解决我们公司 AI 小组生成的 Markdown 文件共享问题。通常,AI 产生的内容是 Markdown 格式,但分享给他人查看时,需要一个可直接访问的 HTML 页面链接。这个项目正是为此需求而生,它允许用户上传 Markdown 文件,系统将自动转换为美观的 HTML 页面,并提供唯一的访问链接。

Markdown to 在线 HTML 链接转换器,本篇文章你将学会:

  • 使用 VitePress 构建 Markdown 到 HTML 的转换服务,实现优雅的渲染效果
  • 通过 Koa 搭建文件上传和 API 接口服务,处理文件存储和路由转发
  • 利用 Docker + GitHub Actions 自动化部署到腾讯云服务器上,并实现域名访问
  • 配置 Nginx 作为反向代理,实现跨域和安全配置
  • 设计前端单页面应用,提供良好的用户体验

体验地址

截屏2025-03-26 19.32.24.png

可以先行下载代码后阅读,觉得还行的话希望能给仓库点个 star。下面开始动手吧!

项目初始化与环境准备

  • 首先,我们需要安装 Node.js (推荐 v16 以上版本)
  • 安装依赖并初始化项目
# 创建项目目录
$ mkdir mdToHtml
$ cd mdToHtml

# 初始化npm项目
$ npm init -y

# 安装核心依赖
$ yarn add vitepress koa koa-router @koa/multer koa-static

项目结构设计

我们的项目结构如下:

├── src/
│   ├── server/         # 服务器代码
│   │   ├── index.js    # Koa服务器主文件
│   │   └── public/     # 静态资源文件
│   └── content/        # 内容相关资源
├── docs/               # VitePress文档目录
│   ├── .vitepress/     # VitePress配置
│   │   ├── config.js   # 主配置文件
│   │   └── theme/      # 自定义主题
│   └── *.md            # Markdown文件
├── simple-uploader/    # 简单的上传界面
│   └── mdUpload.html   # 独立的上传页面
├── Dockerfile          # Docker构建文件
├── nginx.conf          # Nginx配置示例
├── package.json        # 项目依赖
└── README.md           # 项目说明

接下来,让我们修改 package.json 文件,添加必要的脚本命令:

{
  "name": "mdtohtml",
  "version": "1.0.0",
  "description": "Convert markdown files to HTML with VitePress",
  "main": "src/server/index.js",
  "scripts": {
    "dev": "node src/server/index.js",
    "build": "vitepress build docs",
    "start": "yarn build && node src/server/index.js"
  }
}

这些脚本命令将帮助我们在开发时运行服务器、构建 VitePress 网站并启动生产服务。

VitePress 配置:Markdown 到 HTML 的转换引擎

VitePress 是一个基于 Vue 的静态网站生成器,特别适合文档网站的构建。在我们的项目中,我们利用它将 Markdown 文件转换为美观的 HTML 页面。

首先,创建 VitePress 配置目录和文件:

$ mkdir -p docs/.vitepress
$ touch docs/.vitepress/config.js
$ mkdir -p docs/.vitepress/theme
$ touch docs/.vitepress/theme/index.js

配置 VitePress

编辑 docs/.vitepress/config.js 文件:

module.exports = {
  title: 'Markdown to HTML',
  description: 'Convert Markdown to HTML',
  base: '/md/',
  head: [
    ['style', {}, `
      /* 隐藏导航栏和标题 */
      .VPNav, 
      .VPNavBar,
      .VPNavBarTitle,
      .VPLocalNav,
      .header-anchor,
      .VPHomeHero,
      .Layout > header,
      .VPSkipLink {
        display: none !important;
      }
      
      /* 移除页面顶部的空白 */
      .VPContent {
        padding-top: 0 !important;
      }
      
      /* 调整内容区域样式 */
      .VPDoc .container {
        max-width: 90% !important;
        padding: 20px !important;
      }
      
      /* 隐藏标题 */
      .vp-doc h1:first-child {
        display: none !important;
      }
      
      /* 覆盖任何固定定位 */
      .fixed {
        position: static !important;
      }
      
      /* 移除页脚 */
      .VPDocFooter {
        display: none !important;
      }
    `]
  ],
  themeConfig: {
    nav: false,
    sidebar: false,
    footer: false,
    docFooter: false,
    outline: false,
    outlineTitle: false,
    lastUpdated: false,
    socialLinks: false,
    search: false,
    darkMode: false,
    aside: false,
    asideLevels: 0
  }
}

这个配置文件做了几件重要的事情:

  1. 设置网站的基本路径为 /md/,这样所有页面都会在这个路径下
  2. 通过内联 CSS 样式隐藏了 VitePress 默认的导航栏、侧边栏等 UI 元素
  3. 调整了内容区域的样式,使其更加适合纯内容展示
  4. 关闭了所有不必要的功能,如导航、搜索、暗黑模式等

创建默认主题

编辑 docs/.vitepress/theme/index.js 文件:

// 导入默认主题
import DefaultTheme from 'vitepress/theme'

// 简单导出默认主题,不做修改
export default DefaultTheme

创建示例主页

创建 docs/index.md 文件作为示例主页:

# Welcome to Markdown to HTML Converter

This is a sample page generated by VitePress. You can upload your own Markdown files to convert them to beautiful HTML pages.

## Features

- Easy to use
- Beautiful design
- Fast conversion
- Mobile friendly

## How to use

1. Go to the homepage
2. Upload your Markdown file
3. Get your unique URL
4. Share with others

构建 Koa 服务器

现在,我们需要一个服务器来处理 Markdown 文件的上传和转换。我们将使用 Koa.js 框架来构建这个服务器,这是一个轻量级的 Node.js Web 框架,它提供了优雅的中间件架构,非常适合构建 API 和服务。

1. 创建基础服务器结构

首先,创建服务器主文件 src/server/index.js,并设置基本的 Koa 应用结构:

const Koa = require('koa');
const Router = require('koa-router');
const multer = require('@koa/multer');
const path = require('path');
const fs = require('fs');
const serve = require('koa-static');
const { execSync } = require('child_process');

const app = new Koa();
const router = new Router();

这里我们导入了几个重要的依赖:

  • koa: 核心框架
  • koa-router: 用于路由管理
  • @koa/multer: 处理文件上传
  • pathfs: Node.js 内置模块,用于文件路径和文件系统操作
  • koa-static: 提供静态文件服务
  • child_process: 用于执行命令行命令(我们将用它来运行 VitePress 构建)

2. 添加错误处理中间件

错误处理是任何应用的重要部分,让我们添加一个错误处理中间件:

// 错误处理中间件
app.use(async (ctx, next) => {
    try {
        await next();
    } catch (err) {
        console.error('服务器错误:', err);
        console.error('错误堆栈:', err.stack);
        ctx.status = err.status || 500;
        ctx.body = {
            success: false,
            error: err.message || '服务器内部错误',
            details: process.env.NODE_ENV === 'development' ? err.stack : undefined
        };
    }
});

这个中间件会捕获所有在后续中间件中发生的错误,记录错误日志,并向客户端返回适当的错误响应。

3. 添加 CORS 中间件

跨域资源共享(CORS)允许前端从不同的域访问我们的 API:

// CORS 中间件
app.use(async (ctx, next) => {
    // 允许所有来源
    ctx.set('Access-Control-Allow-Origin', '*');
    // 允许的 HTTP 方法
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
    // 允许的请求头
    ctx.set('Access-Control-Allow-Headers', '*');
    // 预检请求的有效期
    ctx.set('Access-Control-Max-Age', '86400');
    
    // 处理 OPTIONS 请求
    if (ctx.method === 'OPTIONS') {
        ctx.status = 204;
        return;
    }
    
    await next();
});

这个中间件为所有响应添加了 CORS 相关的头信息,使我们的 API 可以被任何域的前端访问。

4. 添加日志中间件

为了更好地调试和监控请求,添加一个简单的日志中间件:

// 日志中间件
app.use(async (ctx, next) => {
    const start = Date.now();
    console.log('=== 新请求开始 ===');
    console.log(`收到请求: ${ctx.method} ${ctx.url}`);
    console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
    console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
    console.log('Content-Type:', ctx.request.headers['content-type']);
    
    await next();
    
    const ms = Date.now() - start;
    console.log(`请求完成: ${ctx.method} ${ctx.url} - ${ms}ms`);
    console.log('响应状态:', ctx.status);
    console.log('响应体:', JSON.stringify(ctx.body, null, 2));
    console.log('=== 请求结束 ===\n');
});

这个中间件会记录每个请求的详情、处理时间和响应内容,帮助我们了解服务器的运行状况。

5. 配置文件上传功能

接下来,配置 Multer 来处理文件上传:

// 配置 multer 存储
const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        const uploadDir = path.join(__dirname, '../../docs');
        console.log('上传目录路径:', uploadDir);
        console.log('目录是否存在:', fs.existsSync(uploadDir));
        console.log('目录权限:', fs.statSync(uploadDir).mode);
        
        if (!fs.existsSync(uploadDir)) {
            console.log('创建上传目录');
            fs.mkdirSync(uploadDir, { recursive: true });
        }
        cb(null, uploadDir);
    },
    filename: function (req, file, cb) {
        // 生成唯一的文件名
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
        const filename = uniqueSuffix + path.extname(file.originalname);
        console.log('生成的文件名:', filename);
        cb(null, filename);
    }
});

const upload = multer({ 
    storage: storage,
    fileFilter: (req, file, cb) => {
        if (file.mimetype === 'text/markdown' || file.originalname.endsWith('.md')) {
            cb(null, true);
        } else {
            cb(new Error('只允许上传 Markdown 文件'), false);
        }
    }
});

这段代码做了几件事:

  1. 配置上传文件的存储位置为 docs 目录
  2. 为每个上传的文件生成一个基于时间戳的唯一文件名
  3. 添加文件过滤器,只允许上传 Markdown 文件

6. 实现文件上传处理逻辑

现在,我们需要实现处理文件上传的核心逻辑:

// 处理文件上传的中间件
const handleUpload = async (ctx) => {
    try {
        console.log('=== 文件上传处理开始 ===');
        console.log('Content-Type:', ctx.request.headers['content-type']);
        console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
        console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
        console.log('文件:', ctx.request.file);
        
        if (!ctx.request.file) {
            console.log('没有文件被上传');
            ctx.status = 400;
            ctx.body = { 
                success: false, 
                error: '没有上传文件',
                details: '请确保使用 POST 方法,Content-Type 为 multipart/form-data,并且文件字段名为 "file"'
            };
            return;
        }

        console.log('文件信息:', {
            originalname: ctx.request.file.originalname,
            filename: ctx.request.file.filename,
            path: ctx.request.file.path,
            mimetype: ctx.request.file.mimetype,
            size: ctx.request.file.size
        });

        // 检查文件是否成功保存
        if (!fs.existsSync(ctx.request.file.path)) {
            console.error('文件保存失败:', ctx.request.file.path);
            throw new Error('文件保存失败');
        }

        // 构建相对路径
        const relativePath = path.relative(path.join(__dirname, '../../docs'), ctx.request.file.path);
        const url = `/${relativePath.replace(/\\/g, '/')}`;
        
        // 构建完整URL (不带.md扩展名)
        const pathWithoutExt = url.replace(/\.md$/, '');
        const fullUrl = `${ctx.protocol}://${ctx.host}/md${pathWithoutExt}`;
        
        // 使用VitePress构建
        console.log('开始构建VitePress站点...');
        try {
            execSync('yarn build', { stdio: 'inherit' });
            console.log('VitePress构建完成');
        } catch (error) {
            console.error('VitePress构建失败:', error);
            throw new Error('HTML生成失败');
        }
        
        ctx.body = {
            success: true,
            url: pathWithoutExt,
            fullUrl: fullUrl
        };
    } catch (error) {
        console.error('处理上传时出错:', error);
        ctx.status = 500;
        ctx.body = {
            success: false,
            error: error.message
        };
    }
};

这个处理器完成以下步骤:

  1. 检查是否有文件上传
  2. 验证文件是否成功保存
  3. 构建文件的相对路径和 URL
  4. 执行 VitePress 构建命令,将 Markdown 转换为 HTML
  5. 返回成功响应,包含生成的 URL

7. 设置路由和静态文件服务

最后,设置 API 路由和静态文件服务:

// API 路由
router.post('/api/upload', upload.single('file'), handleUpload);

// 测试路由
router.get('/api/test', (ctx) => {
    console.log('收到测试请求');
    ctx.body = { success: true, message: 'API 服务器正常运行' };
});

// 添加一个简单的测试路由
router.get('/test', (ctx) => {
    console.log('收到简单测试请求');
    ctx.body = { success: true, message: '简单测试路由正常工作' };
});

// 设置静态文件服务
const distPath = path.join(__dirname, '../../docs/.vitepress/dist');
app.use(serve(distPath));

// 使用路由
app.use(router.routes()).use(router.allowedMethods());

const PORT = process.env.PORT || 5555;
app.listen(PORT, () => {
    console.log(`服务器运行在 http://localhost:${PORT}`);
    console.log(`API 端点: http://localhost:${PORT}/api/upload`);
    console.log(`测试端点: http://localhost:${PORT}/api/test`);
    console.log(`静态文件目录: ${distPath}`);
});

这里我们:

  1. 设置了 /api/upload POST 路由处理文件上传
  2. 添加了两个测试路由,方便检查服务器状态
  3. 配置了静态文件服务,提供对 VitePress 生成的 HTML 文件的访问
  4. 启动服务器,监听指定端口

完整的服务器代码

将上述所有部分组合起来,就是完整的 src/server/index.js 文件:

const Koa = require('koa');
const Router = require('koa-router');
const multer = require('@koa/multer');
const path = require('path');
const fs = require('fs');
const serve = require('koa-static');
const { execSync } = require('child_process');

const app = new Koa();
const router = new Router();

// 错误处理中间件
app.use(async (ctx, next) => {
    try {
        await next();
    } catch (err) {
        console.error('服务器错误:', err);
        console.error('错误堆栈:', err.stack);
        ctx.status = err.status || 500;
        ctx.body = {
            success: false,
            error: err.message || '服务器内部错误',
            details: process.env.NODE_ENV === 'development' ? err.stack : undefined
        };
    }
});

// CORS 中间件
app.use(async (ctx, next) => {
    // 允许所有来源
    ctx.set('Access-Control-Allow-Origin', '*');
    // 允许的 HTTP 方法
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
    // 允许的请求头
    ctx.set('Access-Control-Allow-Headers', '*');
    // 预检请求的有效期
    ctx.set('Access-Control-Max-Age', '86400');
    
    // 处理 OPTIONS 请求
    if (ctx.method === 'OPTIONS') {
        ctx.status = 204;
        return;
    }
    
    await next();
});

// 日志中间件
app.use(async (ctx, next) => {
    const start = Date.now();
    console.log('=== 新请求开始 ===');
    console.log(`收到请求: ${ctx.method} ${ctx.url}`);
    console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
    console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
    console.log('Content-Type:', ctx.request.headers['content-type']);
    
    await next();
    
    const ms = Date.now() - start;
    console.log(`请求完成: ${ctx.method} ${ctx.url} - ${ms}ms`);
    console.log('响应状态:', ctx.status);
    console.log('响应体:', JSON.stringify(ctx.body, null, 2));
    console.log('=== 请求结束 ===\n');
});

// 配置 multer 存储
const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        const uploadDir = path.join(__dirname, '../../docs');
        console.log('上传目录路径:', uploadDir);
        console.log('目录是否存在:', fs.existsSync(uploadDir));
        console.log('目录权限:', fs.statSync(uploadDir).mode);
        
        if (!fs.existsSync(uploadDir)) {
            console.log('创建上传目录');
            fs.mkdirSync(uploadDir, { recursive: true });
        }
        cb(null, uploadDir);
    },
    filename: function (req, file, cb) {
        // 生成唯一的文件名
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
        const filename = uniqueSuffix + path.extname(file.originalname);
        console.log('生成的文件名:', filename);
        cb(null, filename);
    }
});

const upload = multer({ 
    storage: storage,
    fileFilter: (req, file, cb) => {
        if (file.mimetype === 'text/markdown' || file.originalname.endsWith('.md')) {
            cb(null, true);
        } else {
            cb(new Error('只允许上传 Markdown 文件'), false);
        }
    }
});

// 处理文件上传的中间件
const handleUpload = async (ctx) => {
    try {
        console.log('=== 文件上传处理开始 ===');
        console.log('Content-Type:', ctx.request.headers['content-type']);
        console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
        console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
        console.log('文件:', ctx.request.file);
        
        if (!ctx.request.file) {
            console.log('没有文件被上传');
            ctx.status = 400;
            ctx.body = { 
                success: false, 
                error: '没有上传文件',
                details: '请确保使用 POST 方法,Content-Type 为 multipart/form-data,并且文件字段名为 "file"'
            };
            return;
        }

        console.log('文件信息:', {
            originalname: ctx.request.file.originalname,
            filename: ctx.request.file.filename,
            path: ctx.request.file.path,
            mimetype: ctx.request.file.mimetype,
            size: ctx.request.file.size
        });

        // 检查文件是否成功保存
        if (!fs.existsSync(ctx.request.file.path)) {
            console.error('文件保存失败:', ctx.request.file.path);
            throw new Error('文件保存失败');
        }

        // 构建相对路径
        const relativePath = path.relative(path.join(__dirname, '../../docs'), ctx.request.file.path);
        const url = `/${relativePath.replace(/\\/g, '/')}`;
        
        // 构建完整URL (不带.md扩展名)
        const pathWithoutExt = url.replace(/\.md$/, '');
        const fullUrl = `${ctx.protocol}://${ctx.host}/md${pathWithoutExt}`;
        
        // 使用VitePress构建
        console.log('开始构建VitePress站点...');
        try {
            execSync('yarn build', { stdio: 'inherit' });
            console.log('VitePress构建完成');
        } catch (error) {
            console.error('VitePress构建失败:', error);
            throw new Error('HTML生成失败');
        }
        
        ctx.body = {
            success: true,
            url: pathWithoutExt,
            fullUrl: fullUrl
        };
    } catch (error) {
        console.error('处理上传时出错:', error);
        ctx.status = 500;
        ctx.body = {
            success: false,
            error: error.message
        };
    }
};

// API 路由
router.post('/api/upload', upload.single('file'), handleUpload);

// 测试路由
router.get('/api/test', (ctx) => {
    console.log('收到测试请求');
    ctx.body = { success: true, message: 'API 服务器正常运行' };
});

// 添加一个简单的测试路由
router.get('/test', (ctx) => {
    console.log('收到简单测试请求');
    ctx.body = { success: true, message: '简单测试路由正常工作' };
});

// 设置静态文件服务
const distPath = path.join(__dirname, '../../docs/.vitepress/dist');
app.use(serve(distPath));

// 使用路由
app.use(router.routes()).use(router.allowedMethods());

const PORT = process.env.PORT || 5555;
app.listen(PORT, () => {
    console.log(`服务器运行在 http://localhost:${PORT}`);
    console.log(`API 端点: http://localhost:${PORT}/api/upload`);
    console.log(`测试端点: http://localhost:${PORT}/api/test`);
    console.log(`静态文件目录: ${distPath}`);
});

8. 运行服务器并进行测试

现在,我们可以运行服务器并测试它了:

# 确保在项目根目录下
$ yarn dev

你应该会看到类似以下的输出:

服务器运行在 http://localhost:5555
API 端点: http://localhost:5555/api/upload
测试端点: http://localhost:5555/api/test
静态文件目录: /path/to/your/project/docs/.vitepress/dist

9. 使用 Apifox 测试接口

Apifox 是一个 API 测试工具,我们可以用它来测试我们的接口。按照以下步骤操作:

  1. 安装 Apifox:如果还没有安装,请从 Apifox 官网 下载并安装

  2. 创建测试项目

    • 打开 Apifox,创建一个新项目
    • 添加一个新的 API 请求
  3. 测试 /api/test 接口

    • 请求方式:GET
    • URL:http://localhost:5555/api/test
    • 点击"发送"按钮
    • 你应该会收到类似以下的响应:
    {
      "success": true,
      "message": "API 服务器正常运行"
    }
    
  4. 测试文件上传接口

    • 请求方式:POST
    • URL:http://localhost:5555/api/upload
    • 请求类型:选择 multipart/form-data
    • 添加一个字段:
      • 名称:file
      • 类型:File
      • 选择一个 Markdown 文件(.md 后缀)
    • 点击"发送"按钮
    • 如果成功,你应该会收到类似以下的响应:
    {
      "success": true,
      "url": "/1634567890-123456789",
      "fullUrl": "http://localhost:5555/md/1634567890-123456789"
    }
    
  5. 验证生成的 HTML

    • 复制返回中的 fullUrl
    • 在浏览器中打开该 URL
    • 你应该能看到你上传的 Markdown 文件已经被转换为 HTML 页面

企业微信截图_fe740d74-e812-41d1-8466-13013574c23a.png

如果所有测试都成功,恭喜你!你已经成功构建了一个 Markdown 到 HTML 的转换服务。

10. 故障排查

如果你遇到了问题,以下是一些常见问题的解决方法:

  • 错误:找不到 docs/.vitepress/dist 目录

    • 确保你已经执行了 yarn build 命令
    • 检查 VitePress 是否正确安装
  • 上传失败,提示"只允许上传 Markdown 文件"

    • 确保你上传的文件扩展名为 .md
    • 检查文件的 MIME 类型
  • 访问生成的 URL 返回 404

    • 确保 VitePress 构建成功
    • 检查输出的日志中是否有错误信息
    • 验证静态文件服务是否正确配置
  • CORS 错误

    • 如果从其他域访问,确保 CORS 中间件正确配置
    • 检查浏览器控制台中的错误信息

创建前端上传界面

为了让用户能够方便地上传Markdown文件,我们创建一个简单的前端界面。

创建 simple-uploader/mdUpload.html 文件:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Markdown文件上传</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
  <style>
    body {
      font-family: 'Helvetica Neue', Arial, sans-serif;
      background-color: #f5f7fa;
      margin: 0;
      padding: 0;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
    }
    .container {
      background-color: #fff;
      border-radius: 10px;
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
      padding: 2rem;
      width: 90%;
      max-width: 600px;
    }
    h1 {
      color: #2c3e50;
      text-align: center;
      margin-bottom: 2rem;
      font-weight: 600;
    }
    .upload-area {
      border: 2px dashed #dcdfe6;
      border-radius: 8px;
      padding: 2rem;
      text-align: center;
      margin-bottom: 1.5rem;
      transition: all 0.3s;
      background-color: #f9fafc;
    }
    .upload-area:hover, .upload-area.dragover {
      border-color: #409eff;
      background-color: #ecf5ff;
    }
    .upload-text {
      color: #606266;
      margin-bottom: 1rem;
    }
    .upload-info {
      font-size: 0.9rem;
      color: #909399;
    }
    .btn {
      display: inline-block;
      padding: 0.75rem 1.5rem;
      border-radius: 5px;
      border: none;
      background-color: #409eff;
      color: white;
      font-size: 1rem;
      cursor: pointer;
      transition: all 0.3s;
      margin-top: 1rem;
    }
    .btn:hover {
      background-color: #66b1ff;
    }
    .file-input {
      display: none;
    }
    .selected-file {
      margin-top: 1rem;
      padding: 0.75rem;
      border-radius: 5px;
      background-color: #f0f9eb;
      color: #67c23a;
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
    .progress-container {
      margin-top: 1.5rem;
      height: 8px;
      background-color: #ebeef5;
      border-radius: 4px;
      overflow: hidden;
    }
    .progress-bar {
      height: 100%;
      background-color: #409eff;
      border-radius: 4px;
      transition: width 0.3s;
    }
    .result-container {
      margin-top: 1.5rem;
      padding: 1rem;
      border-radius: 5px;
      background-color: #f0f9eb;
      color: #67c23a;
    }
    .result-url {
      word-break: break-all;
      margin-top: 0.5rem;
      padding: 0.75rem;
      background-color: #f5f7fa;
      border-radius: 4px;
      border: 1px solid #e4e7ed;
    }
    .copy-btn {
      border: none;
      background-color: #67c23a;
      color: white;
      padding: 0.5rem 1rem;
      border-radius: 4px;
      margin-top: 0.5rem;
      cursor: pointer;
      transition: all 0.3s;
    }
    .copy-btn:hover {
      background-color: #85ce61;
    }
    .error-message {
      margin-top: 1.5rem;
      padding: 1rem;
      border-radius: 5px;
      background-color: #fef0f0;
      color: #f56c6c;
    }
  </style>
</head>
<body>
  <div id="app" class="container">
    <h1>Markdown文件上传</h1>
    
    <div 
      class="upload-area"
      :class="{ 'dragover': isDragging }"
      @dragover.prevent="isDragging = true"
      @dragleave.prevent="isDragging = false"
      @drop.prevent="onFileDrop">
      <p class="upload-text">拖拽Markdown文件到此处,或</p>
      <input 
        type="file" 
        class="file-input" 
        ref="fileInput"
        accept=".md,.markdown" 
        @change="onFileSelected">
      <button class="btn" @click="$refs.fileInput.click()">选择文件</button>
      <p class="upload-info">只支持Markdown文件 (.md, .markdown)</p>
    </div>
    
    <div v-if="selectedFile" class="selected-file">
      <span>已选择: {{ selectedFile.name }}</span>
      <button class="btn" @click="uploadWithRetry" :disabled="uploading">上传</button>
    </div>
    
    <div v-if="uploading" class="progress-container">
      <div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
    </div>
    
    <div v-if="error" class="error-message">
      {{ error }}
    </div>
    
    <div v-if="uploadResult" class="result-container">
      <h3>上传成功!</h3>
      <p>生成的HTML文件URL:</p>
      <div class="result-url">{{ uploadResult.url }}</div>
      <button class="copy-btn" @click="copyUrl">复制链接</button>
      
      <div v-if="uploadResult.fullUrl" style="margin-top: 1rem;">
        <p>完整URL:</p>
        <div class="result-url">{{ uploadResult.fullUrl }}</div>
        <button class="copy-btn" @click="copyFullUrl">复制完整链接</button>
      </div>
    </div>
  </div>

  <script>
    const { createApp, ref } = Vue;
    
    createApp({
      setup() {
        const isDragging = ref(false);
        const selectedFile = ref(null);
        const uploading = ref(false);
        const uploadProgress = ref(0);
        const error = ref('');
        const uploadResult = ref(null);
        const fileInput = ref(null);
        
        const resetForm = () => {
          selectedFile.value = null;
          uploadProgress.value = 0;
          error.value = '';
        };
        
        const onFileSelected = (event) => {
          const files = event.target.files;
          if (files && files.length > 0) {
            const file = files[0];
            if (file.name.endsWith('.md') || file.name.endsWith('.markdown')) {
              selectedFile.value = file;
              error.value = '';
            } else {
              error.value = '请选择Markdown文件 (.md, .markdown)';
              selectedFile.value = null;
            }
          }
        };
        
        const onFileDrop = (event) => {
          isDragging.value = false;
          const files = event.dataTransfer.files;
          if (files && files.length > 0) {
            const file = files[0];
            if (file.name.endsWith('.md') || file.name.endsWith('.markdown')) {
              selectedFile.value = file;
              error.value = '';
            } else {
              error.value = '请选择Markdown文件 (.md, .markdown)';
              selectedFile.value = null;
            }
          }
        };
        
        const uploadWithRetry = async () => {
          if (!selectedFile.value) {
            error.value = '请先选择文件';
            return;
          }
          
          const apiUrl = '/api/upload';
          const maxRetries = 3;
          let retries = 0;
          
          const doUpload = async () => {
            try {
              uploading.value = true;
              error.value = '';
              
              const formData = new FormData();
              formData.append('file', selectedFile.value);
              
              const response = await axios.post(apiUrl, formData, {
                headers: {
                  'Content-Type': 'multipart/form-data'
                },
                onUploadProgress: (progressEvent) => {
                  const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
                  uploadProgress.value = percentCompleted;
                }
              });
              
              if (response.data && response.data.success) {
                uploadResult.value = {
                  url: response.data.url,
                  fullUrl: response.data.fullUrl
                };
                uploading.value = false;
                // 重置选择的文件
                fileInput.value.value = '';
                selectedFile.value = null;
              } else {
                throw new Error('上传失败');
              }
            } catch (err) {
              if (retries < maxRetries) {
                retries++;
                error.value = `上传失败,正在重试 (${retries}/${maxRetries})...`;
                setTimeout(doUpload, 1000);
              } else {
                error.value = `上传失败:${err.message || '未知错误'}`;
                uploading.value = false;
              }
            }
          };
          
          doUpload();
        };
        
        const copyUrl = () => {
          if (uploadResult.value && uploadResult.value.url) {
            navigator.clipboard.writeText(uploadResult.value.url)
              .then(() => {
                alert('URL已复制到剪贴板');
              })
              .catch((err) => {
                console.error('复制失败:', err);
                alert('复制失败,请手动复制');
              });
          }
        };
        
        const copyFullUrl = () => {
          if (uploadResult.value && uploadResult.value.fullUrl) {
            navigator.clipboard.writeText(uploadResult.value.fullUrl)
              .then(() => {
                alert('完整URL已复制到剪贴板');
              })
              .catch((err) => {
                console.error('复制失败:', err);
                alert('复制失败,请手动复制');
              });
          }
        };
        
        return {
          isDragging,
          selectedFile,
          uploading,
          uploadProgress,
          error,
          uploadResult,
          fileInput,
          onFileSelected,
          onFileDrop,
          uploadWithRetry,
          copyUrl,
          copyFullUrl
        };
      }
    }).mount('#app');
  </script>
</body>
</html>

Docker 容器化部署

为了简化部署过程并确保一致性,我们将使用 Docker 容器化我们的应用。

创建 Dockerfile 文件:

# 使用 Node.js Alpine 镜像作为基础镜像
FROM node:18-alpine

# 设置工作目录
WORKDIR /app

# 创建 yarn 缓存目录并设置权限
RUN mkdir -p /home/node/.cache/yarn && \
    chown -R node:node /home/node/.cache

# 复制 package.json 和 yarn.lock
COPY package.json yarn.lock ./

# 安装依赖并清理缓存
RUN yarn install && \
    yarn cache clean

# 复制源代码
COPY . .

# 创建必要的目录并设置权限
RUN mkdir -p docs/.vitepress/dist && \
    chmod -R 777 docs && \
    chown -R node:node /app

# 切换到非 root 用户
USER node

# 设置 yarn 缓存目录
ENV YARN_CACHE_FOLDER=/home/node/.cache/yarn

# 暴露端口
EXPOSE 5555

# 启动命令 - 使用 start 命令启动服务
CMD ["yarn", "start"]

我们使用了官方的 node:18-alpine 镜像作为基础,这是一个轻量级的 Alpine Linux 版本,能够显著减小 Docker 镜像的大小。

构建和运行 Docker 镜像

使用以下命令构建 Docker 镜像:

$ docker build -t mdtohtml .

构建完成后,可以使用以下命令运行容器:

$ docker run -p 5555:5555 mdtohtml

现在,你可以通过访问 http://localhost:5555 来访问应用。

注意:如果你在腾讯云服务器上部署此项目,请确保在腾讯云安全组/防火墙中开放 5555 端口,否则外部将无法访问服务。具体步骤:

  1. 登录腾讯云控制台
  2. 进入"云服务器" > "实例" > 选择你的实例
  3. 点击"安全组" > "配置规则" > "添加规则"
  4. 添加入站规则,协议选择"TCP",端口为"5555"
  5. 保存配置

配置 Nginx 反向代理

在生产环境中,我们通常需要配置 Nginx 作为反向代理,处理 HTTPS 请求、静态资源缓存和路由转发等工作。

配置 nginx :

server {
    listen 80;  # 添加 HTTP 端口监听
    listen 443 ssl;  # 已有的 HTTPS 端口监听
    server_name junfeng530.xyz;  # 你的域名

    ssl_certificate /www/server/panel/vhost/cert/junfeng530.xyz/fullchain.pem;  # 替换为你的证书路径
    ssl_certificate_key /www/server/panel/vhost/cert/junfeng530.xyz/privkey.pem;  # 替换为你的私钥路径

    # 添加访问日志
    access_log /www/wwwlogs/md-access.log;
    error_log /www/wwwlogs/md-error.log debug;

    # 添加 /md/ 路径的代理规则
    location /md/ {
        # 添加调试日志
        add_header X-Debug-Message "Processing /md/ location" always;
        
        # CORS 配置 - 添加更完整的跨域支持
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
        add_header 'Access-Control-Allow-Headers' '*' always;
        add_header 'Access-Control-Max-Age' '86400' always;
        
        # 处理 OPTIONS 请求
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' '*' always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
            add_header 'Access-Control-Allow-Headers' '*' always;
            add_header 'Access-Control-Max-Age' '86400' always;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' '0';
            return 204;
        }
        
        # 代理到 Docker 容器,检查是否应该使用 5000 端口
        proxy_pass http://127.0.0.1:5555/;  # 使用 5555 端口 (Docker 内部)
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # 添加这些配置以支持文件上传
        client_max_body_size 50M;
        proxy_connect_timeout 300s;
        proxy_send_timeout 300s;
        proxy_read_timeout 300s;
        
        # 禁用缓冲以处理大型请求
        proxy_buffering off;
        
        # 处理 URL 重写,将 /md 前缀移除
        rewrite ^/md/(.*)$ /$1 break;
    }
}

这个配置文件实现了以下功能:

  1. 配置 HTTP 和 HTTPS 监听
  2. 设置 SSL 证书和密钥路径
  3. 配置访问和错误日志
  4. /md/ 路径设置反向代理,将请求转发到我们的 Docker 容器
  5. 添加 CORS 配置,支持跨域请求
  6. 设置适当的文件上传限制和超时时间
  7. 处理 URL 重写,移除 /md 前缀

使用 GitHub Actions 自动化部署到阿里云

为了实现自动化部署,我们将使用 GitHub Actions,这样每当我们推送代码到 GitHub 仓库时,系统就会自动构建 Docker 镜像并部署到腾讯云服务器上。

创建 .github/workflows/deploy.yml 文件:

name: Deploy to Tencent Cloud

on:
  push:
    branches: [ main ]  # 当推送到 main 分支时触发

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Login to Aliyun Container Registry
        uses: docker/login-action@v1
        with:
          registry: registry.cn-shenzhen.aliyuncs.com
          username: ${{ secrets.ALIYUN_USERNAME }}
          password: ${{ secrets.ALIYUN_PASSWORD }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v2
        with:
          context: .
          push: true
          tags: registry.cn-shenzhen.aliyuncs.com/jiang-nest/jiang-md-to-html:latest

      - name: Deploy to Tencent Cloud
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.TENCENT_HOST }}
          username: ${{ secrets.TENCENT_USERNAME }}
          key: ${{ secrets.TENCENT_SSH_KEY }}
          script: |
            # 登录阿里云容器镜像服务
            docker login --username=${{ secrets.ALIYUN_USERNAME }} --password=${{ secrets.ALIYUN_PASSWORD }} registry.cn-shenzhen.aliyuncs.com
            
            # 拉取最新镜像
            docker pull registry.cn-shenzhen.aliyuncs.com/jiang-nest/jiang-md-to-html:latest
            
            # 停止并删除旧容器
            docker stop jiang-md-to-html || true
            docker rm jiang-md-to-html || true
            
            # 运行新容器
            docker run -d \
              --name jiang-md-to-html \
              -p 5555:5555 \
              registry.cn-shenzhen.aliyuncs.com/jiang-nest/jiang-md-to-html:latest

这个工作流文件实现了以下步骤:

  1. 在推送到 main 分支时触发
  2. 检出代码
  3. 登录到阿里云容器镜像服务
  4. 构建并推送 Docker 镜像到阿里云容器镜像仓库
  5. 通过 SSH 连接到腾讯云服务器
  6. 登录阿里云容器镜像服务
  7. 拉取最新的 Docker 镜像
  8. 停止并删除旧的容器
  9. 运行新的容器,将容器的 5555 端口映射到主机的 5555 端口

要使用这个工作流,你需要在 GitHub 仓库的设置中添加以下 Secrets:

  • ALIYUN_USERNAME: 阿里云容器镜像服务的用户名
  • ALIYUN_PASSWORD: 阿里云容器镜像服务的密码
  • TENCENT_HOST: 腾讯云服务器的 IP 地址
  • TENCENT_USERNAME: 腾讯云服务器的 SSH 用户名
  • TENCENT_SSH_KEY: 腾讯云服务器的 SSH 私钥

设置 GitHub Secrets

  1. 进入你的 GitHub 仓库页面
  2. 点击 "Settings" 选项卡
  3. 在左侧菜单中,选择 "Secrets and variables" > "Actions"
  4. 点击 "New repository secret"
  5. 添加上述提到的 Secrets

完成这些设置后,每当你推送代码到 GitHub 仓库的 main 分支时,GitHub Actions 就会自动构建 Docker 镜像并部署到腾讯云服务器上。

总结与拓展

通过本项目,我们实现了以下功能:

  1. 使用 VitePress 将 Markdown 文件转换为在线 HTML 链接
  2. 通过 Koa 构建了文件上传和 API 服务
  3. 设计了简单的前端上传界面
  4. 使用 Docker 容器化应用
  5. 配置 Nginx 反向代理
  6. 使用 GitHub Actions 实现自动化部署到腾讯云,同时使用阿里云容器镜像服务

这个项目可以进一步拓展,例如:

  • 添加用户认证系统,限制谁可以上传文件
  • 实现文件管理功能,允许用户查看和删除已上传的文件
  • 添加自定义主题支持,允许用户选择不同的样式
  • 集成 Markdown 编辑器,允许用户在线编辑 Markdown 文件
  • 添加图片上传功能,支持在 Markdown 中嵌入图片
  • 实现自动备份功能,定期备份上传的文件

希望这个项目能够帮助你更好地理解现代 Web 应用的开发流程,包括前端、后端、Docker 容器化和自动化部署等方面。如果你有任何问题或建议,欢迎在 GitHub 上提出 issue 或 pull request。

参考资料

AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术

作者 得物技术
2025年3月27日 11:35

一、引言

从2022年12月份OpenAI发布ChatGPT产品至今已有2年多的时间,当大家已经习惯于在对话框中与AI交互,习惯于通过各种Prompt技巧让AI更好的理解并回答我们的问题,似乎默认这就是一种比较好与AI的交互方式了。

然而,这就是我们期盼的与AI交互的形式嘛?这是一种高效的方式嘛?

显然,这是不够的。

我们期望的是:告诉AI我们想要的目标或者任务,AI能够理解深度理解并分析我们的意图、自动的进行任务的拆解、自动的寻找可以使用的工具、自动的进行结果数据的汇总过滤、自动的呈现符合任务的展示形式。同时在任务处理过程中,可以自己完成异常的检测和修改。就如同一位优秀的同学,我们告诉他任务的目标,他可以自己寻找飞书文档、搜索网络知识、使用内部系统、自己编码验证方案可行性,并最终给一份好的解决方案。

二、以「对话为中心」的ChatBot

我们发送一条指令,AI被动的响应指令。即完成一轮人与AI的交互。

具体视频请前往“得物技术”微信公众号观看。

三、以「交付为中心」的多智能体Agent

我们发送一个任务,AI自动分析任务、调用可用的工具、分析结果、过滤数据并自动处理异常,最终呈现解决方案。

完成这样的一个任务,需要多智能体Agent间的协作以及对常用工具的调用。那什么是智能体Agent呢?

具体视频请前往“得物技术”微信公众号观看。

四、什么是智能体Agent

从Prompt到思维链

随着大模型的发展,Prompt工程已成为撬动大模型潜能的核心技术。即使我们普通用户在与大模型的交互中,也通过角色定义(如"资深工程师")或示例引导来优化输出效果,但这类简单提示往往难以突破模型固有的逻辑天花板——就像给赛车装自行车轮胎,再怎么调整也难以突破速度极限。

但偶然间,人们发现了一个神奇的咒语:只需要告诉大模型,你的 think 要 step by step。研究者发现只要加了这个prompt,就能极为显著地改善大模型做数学题的正确率。

大模型的数学与逻辑能力短板,是所有体验过其对话功能的用户都能直观感受到的痛点。这一缺陷严重制约了大模型的商业化落地进程,毕竟没有人敢轻易信任一个逻辑混乱的智能系统能输出可靠的决策结果。于是,提升大模型数学能力,被所有做基础模型的公司当作了第一目标。

研究者试图通过强化思维链来突破这一瓶颈。一个直观的思路是:让模型像人类解题时在草稿纸上推演那样,通过 "step by step" 的方式展开逻辑链条 —— 在这个过程中,包含假设、演绎、反思、纠错等一系列思维活动。既然人类通过这种结构化的思考方式能够有效解决数学问题,那么大模型是否也能通过类似机制实现能力跃迁?这一猜想推动着研究向纵深发展,最终形成了思维链技术的核心框架。这样的观念经过继续钻研,最终就构成了思维链,思维链是一个能以最小的代价,而非常显著提升模型智力水平(逻辑能力、解题能力、代码能力)的技术。

值得注意的是,2025 年春节期间引发广泛关注的 DeepSeek 大模型,正是思维链技术的成功实践典范。尽管 DeepSeek 并非首创者,但其通过创新性地融合混合专家(MoE)架构与强化学习技术,显著提升了思维链推理的计算效率与性能表现。这种技术优化使得 DeepSeek 在保持高精度推理的同时,大幅降低了计算成本,最终实现了屠榜级表现。

ReAct架构

如果说思维链(COT)是给 AI 装上了人类的 "草稿纸",那么 ReAct 框架就是为它配备了 "双手"—— 让 AI 不仅能在脑子里推演,还能主动采取行动获取信息。这种 "思考 + 行动" 的组合,正在把大模型从 "纸上谈兵" 的理论家,变成能解决现实问题的实干家。

ReAct 的核心在于将**推理(Reasoning)与行动(Action)**紧密结合。当模型面对复杂问题时,会先像人类一样拆解思考步骤,然后根据中间结果调用外部工具(如搜索引擎、数据库、计算器)获取实时数据,再把这些信息整合到后续推理中。

其实,实现一个ReAct很简单,只需要构建Prompt+提供工具+循环执行即可,笔者在这里不进行详细的介绍,只需要给一个Prompt例子,读者就能理解:

尽可能最好地为用户回答接下来的问题,你可以使用以下工具来辅助你:{tools} 使用以下格式:

- 问题:你需要回答的输入问题

- 思考:你需要持续思考下一步采取什么行动 

- 行动:要采取的行动,应该是 [{tool_names}] 中的一个,以及该行动的输入内容 

- 观察:行动并观测结果,并判断结果是否合理 ...(这个思考 / 行动  / 观察可以重复 N 次,直到你认为知道了最终答案 

- 最终答案:原始输入问题的最终答案 

开始! 

- 问题:{input}

Tools支持开发者自定义,比如给予LLM一个查询天气的接口、计算器接口等。

ReAct架构实现了一种**"问题拆解-工具调用-结果整合"闭环机制**,使得开发者仅需通过定义工具集(如天气API、计算器、知识图谱接口)和设计任务引导词,就能将大模型转化为可执行多步骤决策的智能体。最终可以使大模型突破纯文本推理的局限,真正具备了在动态场景中解决开放性问题的工程化能力。

Agent

Agent作为大模型技术的集大成者,通过整合思维链(CoT)的推理能力和ReAct框架的行动机制,构建了具备自主决策与执行能力的智能系统。其核心突破在于将**“大脑”与“四肢”**有机统一,标志着大模型从被动应答迈向主动干预现实的质变。

在架构上,Agent与ReAct差别不大,ReAct是Agent的核心实现范式之一,Agent进一步整合记忆存储、多智能体协作等模块,形成更完整的自主决策系统。下图是一个简单的Agent架构图:

v2ad31f685f1330333011c67eccc3cb64c_1440w.png

Agent处理流程

1-4步会循环进行,直到LLM认为问题已被回答。

1.规划(Planning):

  • 定义:规划是Agent的思维模型,负责拆解复杂任务为可执行的子任务,并评估执行策略。

  • 实现方式:通过大模型提示工程(如ReAct、CoT推理模式)实现,使Agent能够精准拆解任务,分步解决。

2.记忆(Memory):

  • 定义:记忆即信息存储与回忆,包括短期记忆和长期记忆。

  • 实现方式:短期记忆用于存储会话上下文,支持多轮对话;长期记忆则存储用户特征、业务数据等,通常通过向量数据库等技术实现快速存取。

3.工具(Tools):

  • 定义:工具是Agent感知环境、执行决策的辅助手段,如API调用、插件扩展等。

  • 实现方式:通过接入外部工具(如API、插件)扩展Agent的能力,如ChatPDF解析文档、Midjourney文生图等。

4.行动(Action):

  • 定义:行动是Agent将规划与记忆转化为具体输出的过程,包括与外部环境的互动或工具调用。

  • 实现方式:Agent根据规划与记忆执行具体行动,如智能客服回复、查询天气预报、AI机器人抓起物体等。

Manus:一个Agent典型案例

在读完前一节关于智能体(Agent)的技术解析后,读者也许会认为这类系统的工程实现并非难事,实际上也确实是这样。近期爆火的 Agent 产品 Manus 便是典型案例。当用户提出 "定制 7 天日本旅行计划" 的需求时,Manus 能够基于目标,自主进行网络搜索并将信息整合,展现出高度拟人化的任务执行逻辑

2.png

尽管 Manus 目前尚未向普通用户开放,且采用邀请制注册的封闭运营模式,但其通过官方演示视频呈现的强大智能化表现,已在技术圈引发广泛关注。值得关注的是,随着Agent技术的热度攀升,开源社区已迅速涌现出 OpenManus、OWL 等多个复刻项目。

因为Manus并非开源,我们很难了解其技术细节。但好在:

  1. "Manus 的部分技术细节,包括其提示词设计、运行机制等内容被网友通过非官方渠道披露,感兴趣的读者可自行查阅相关公开资料。

  2. 我们可以了解一下大模型上下文协议(Model Context Protocol,MCP),这是 Anthropic (Claude) 主导发布的一个开放的、通用的、有共识的协议标准,虽然Manus不一定用了这个协议,但目前一些相关开源项目也是基于MCP的,本文会在下面介绍MCP。

  3. 目前已有复刻的开源项目Openmanus,笔者会在接下来的章节剖析其源码。

大模型上下文协议(MCP)

MCP是做什么的?

MCP(Model Context Protocol)作为一项开放协议,旨在为应用程序与大型语言模型(LLMs)之间的上下文交互提供标准化框架。其设计理念可类比为数字时代的 "USB-C 接口"—— 正如 USB-C 统一了设备与外设的连接标准,MCP 通过标准化的上下文交互接口,实现了 AI 模型与多样化数据源、工具之间的无缝对接。

如下图所示,图中的MCP server都可以看成一个个工具(如搜索引擎、天气查询),通过“接口”连接到MCP clients(大模型)上,大模型可以使用各种MCP server来更好地处理用户的问题。

此外,下游工具的开发者也可以更好的开发其工具,目前在MCP官网即可了解其各种编程语言的SDK和相关概念。

3.png

MCP架构

MCP 的核心采用客户端-服务器架构,其中 host 可以连接到多个服务器,读者简单看看即可:

img_v3_02kp_bcaed6dcc3e04917a824cf74a340516g.png

  • MCP 主机(MCP Hosts):指需要通过 MCP 协议获取数据的应用程序,涵盖 AI 开发工具(如 Claude Desktop)、集成开发环境(IDEs)等智能应用场景。

  • MCP 客户端(MCP Clients):作为协议的执行者,每个客户端与对应的 MCP 服务器建立一对一的专属连接,负责协议层面的通信交互。

  • MCP 服务器(MCP Servers):轻量化的功能载体,通过标准化的 Model Context Protocol 对外开放特定能力,可视为连接模型与工具的智能桥梁。

  • 本地化数据源(Local Data Sources):包括服务器可安全访问的本地文件系统、数据库及专有服务,构成数据交互的近端生态。

  • 远程服务(Remote Services):通过互联网连接的外部系统,例如各类 API 接口服务,拓展了模型的能力边界。

为什么要用MCP?

从技术演进视角看,MCP 的诞生是提示工程(Prompt Engineering)发展的必然产物。研究表明,结构化的上下文信息能显著提升大模型的任务表现。在传统提示工程中,我们往往需要人工从数据库筛选信息或通过工具检索相关内容,再手动将这些信息注入提示词。然而,随着复杂任务场景的增多,这种手工注入信息的操作变得愈发繁琐且低效。

为解决这一痛点,主流大模型平台(如 OpenAI、Google)先后引入了函数调用(Function Call)机制。该机制允许模型在推理过程中主动调用预定义函数获取数据或执行操作,极大提升了自动化水平。然而,函数调用机制存在显著局限性:其一,不同平台的函数调用 API 存在较大差异,例如 OpenAI 与 Google 的实现方式互不兼容,开发者在切换模型时需重新编写代码,徒增适配成本;其二,该机制在安全性、交互性及复杂场景的扩展性方面仍存在优化空间。

在此背景下,MCP 协议通过标准化的上下文交互接口,为大模型构建了更具普适性的工具调用框架。它不仅解耦了模型与工具的依赖关系,还通过统一的协议规范解决了跨平台兼容性问题。更重要的是,MCP 将上下文管理提升到系统架构层面,为大模型在复杂业务场景中的深度应用提供了可扩展的技术底座。这种从碎片化的提示工程到体系化的上下文协议的演进,标志着大模型应用正在向更高效、更规范的方向迈进。

四、智能体Agent实现的源码剖析(OpenManus项目)

img_v3_02kp_7f7cdb11c5c3435e8bdcc98e38f9cddg.png

OpenManus 是一个基于 MCP 协议的开源智能体实现项目,旨在通过标准化的上下文协议实现大模型与工具的高效协同。当前项目仍处于快速迭代阶段,本文以其 2025 年 3 月 12 日的版本为分析对象。选择该项目的原因如下:

  • 团队背景与代码质量:项目作者来自MetaGPT,具备深厚的工程经验,代码结构清晰且注释完善,兼顾了技术实现与可读性。

  • 部署便捷性:只需通过虚拟环境安装依赖并配置大模型 API Key(如 OpenAI 的 API 密钥),即可快速启动,降低了技术门槛。

  • 技术前沿性:项目紧跟大模型技术发展,且目前仍在不断迭代的过程中。

在经过前面对相关概念的讨论,我们可以得知实现Agent有几个关键的点,读者可以带着问题在项目中寻找答案:

  • Prompt:其结构化的Prompt是什么样的?通过Prompt可以对其架构有一个初步认识。

  • OpenManus:怎么通过大模型思考和处理问题?

  • 工具相关:怎么进行工具注册、工具管理的?工具执行逻辑是什么的?

准备

项目地址:

github.com/mannaandpoe…

构建环境

创建一个python=3.12的虚拟环境

  • 笔者测试了一下,非3.12版本会有一个package不兼容。

  • 可以用conda或python内置的uv,项目文档提供了详细的指令。

安装playwright

  • 如果第一次使用,需要安装playwright。
playwright install
## 或者
python -m playwright install
## 以上命令会安装所有浏览器,如果只需要安装一个浏览器比如firefox
python -m playwright install firefox

配置大模型API Key

  • 可以用DeepSeek或通义千问的API Key,其中通义有免费额度,DeepSeek虽然收费但价格便宜,测试一次使用约1000token,成本不到0.01元。

  • 根据项目文档配置cofig.yaml即可,但项目调用大模型是使用基础的OpenAI API,如果使用其他大模型,可能需要基于对应的官方文档小改一下。

代码

OpenManus客户端

Python OpenManus/main.py即可在终端运行OpenManus,读者也可以尝试其Web版本。

  • 具体会调用20行代码,执行Manus类的方法run()。

img_v3_02kp_037da7610f23414cb15d567f598ac4bg.png

进入OpenManus/app/agent/manus.py查看Manus类,可以发现它继承了ToolCallAgent类,再进入会发现又是继承,有点复杂,这里我画一张关系图。

  • act()执行时使用execute_tools()进行具体的工具执行。

  • 总体来说,Manus类定义了Prompt和可使用的工具。

  • Base类定义了run(),在run()中会循环执行ReAct类的方法step(),直到Finish或达到max_step。

  • step()类会顺序执行ToolCallAgent类的think()和act()。

当然,这里只罗列了重要的组件和方法,一些方法没有画在图中。

img_v3_02kp_e50578ddab27439f91d97a3f5e38943g.jpg

Prompt

一般来说,输入给LLM的prompt分为两种:1)系统 prompt,用于定义模型的角色定位和行为规则;2)用户 prompt(OpenManus称为Next Step Prompt),用于传达具体的任务指令或信息需求。

在OpenManus/app/prompt/manus.py中即可看到Manus的Prompt,这里展示一下中文版,读者基于此可对OpenManus架构有一个初步认识:

  • 系统Prompt(SYSTEM_PROMPT):“你是 OpenManus,一个全能的人工智能助手,旨在解决用户提出的任何任务。你拥有各种可使用的工具,能调用这些工具高效地完成复杂的请求。无论是编程、信息检索、文件处理还是网页浏览,你都能应对自如。”

  • 下一步Prompt(NEXT_STEP_PROMPT):“你可以使用 PythonExecute 与计算机进行交互,通过 FileSaver 保存重要的内容和信息文件,使用 BrowserUseTool 打开浏览器,并使用 GoogleSearch 检索信息。根据用户的需求,主动选择最合适的工具或工具组合。对于复杂的任务,你可以将问题分解,逐步使用不同的工具来解决它。在使用完每个工具后,清晰地解释执行结果并给出下一步的建议。

当然,在实际执行时会对prompt有进一步优化,不过核心的系统定位与任务指导原则是不会改变的。

Manus类

img_v3_02kp_83117adc20bf418fbd98933c2671522g.png

我们先看一下OpenManus拥有的工具,工具也支持自定义,会在后文进行介绍。

  • PythonExecute:执行 Python 代码以与计算机系统交互、进行数据处理、自动化任务等等。

  • FileSaver:在本地保存文件,例如 txt、py、html 等文件。

  • BrowserUseTool:打开、浏览并使用网络浏览器。如果你打开一个本地 HTML 文件,必须提供该文件的绝对路径。

  • GoogleSearch:执行网络信息检索。

  • Terminate:如果LLM认为回答完毕,会调用这个工具终止循环。

Base类

run()

img_v3_02kp_36fbb768418d4f2892b676943131916g.jpg

  • 首先,输入的request就是用户输入的提问。

状态管理

img_v3_02kp_036ebee8ebfd4b4c94cb283d4a071aag.jpg

  • 执行时首先检查代理的当前状态是否为 IDLE(空闲状态)。如果不是空闲状态,会抛出 RuntimeError 异常,因为只有在空闲状态下才能启动代理的执行。

img_v3_02kp_1fa59b67e15247069e103f001a8b2a2g.jpg

  • 当进入循环时前,使用 state_context上下文管理器将代理的状态临时切换到 RUNNING(运行状态)。在上下文管理器中执行的代码块会在进入时将状态切换为指定状态,在退出时恢复到之前的状态。如果在执行过程中发生异常,会将状态切换为 ERROR

Memory管理

我们调用大模型的API,本质是向大模型提供方发http请求,http请求是无状态的。

  • 也就是说,服务端不会保留任何会话信息。对于每次都完成一个独立的任务,无状态是没有任何问题的。但对持续聊天来说,就会出现对之前会话一无所知的情况。

所以为了让大模型持续与用户的对话,一种常见的解决方案就是把聊天历史告诉大模型。

  • 因此,在OpenManus中会进行Memory的管理。

img_v3_02kp_8c1e4d8812b840d9804ed82c2e6b68cg.jpgimg_v3_02kp_c74745982b0042e59b77935079c3b55g.png

  • 用户提供的 request 参数,调用 update_memory 方法将该请求作为用户消息添加到代理的Memory中。

  • 除了这个函数,Manus也在进行think()、act()时也会更新Memory,同时Memory容量也不是无限大的,容量满时需要删除老的Message。

主循环

img_v3_02kp_1ce792754452405cbd686c976d9a2bfg.png

agent本质就是循环执行。

  • step实现参考react step。

  • 循环结束条件:max_steps或者FINISHED状态。

  • 每次执行一个step并获得result——step_result = await self.step()。

  • is_stuck 方法用于检查代理是否陷入了循环(即是否出现了重复的响应)。如果是,则调用 handle_stuck_state 方法处理这种情况,例如添加一个提示来改变策略。

ReAct

step()

img_v3_02kp_3999f1b8a5bb413f826ca4b7c3d8836g.png

  • 这里的逻辑很简单。

ToolcallAgent

Think()

  • 输入:不需要输入,因为用户的question是被存放在Memory中。

  • 输出:一个bool类型,当内部LLM判断需要act()时,为True,否则为Fasle。

询问LLM

img_v3_02kp_ecd6a3006d254268a783101c86d86a0g.png

  • 55行的代码用于调用LLM的API接口,获取回复。

img_v3_02kp_d194c2fca02e47b9be3c05ab5195c25g.png

对应到OpenManus/app/llm.py 233行附近,这里就是基于OpenAI提供的API接口进行对话,具体的参数可参考相应官方文档。

  • 这里会将之前定义的下一步Prompt发给LLM,LLM会根据提供的工具列表,判断是否需要且调用的是哪个工具,当然也可能是:1)不需要工具只进行回复 2)调用Terminate工具结束会话。

下图是一次返回response结果

  • 输入的question是“计算Kobe Bryant的BMI?”,LLM先分析出了要通过浏览器查询资料,因此要use the BrowserUseTool。

  • 根据传入的工具类型等信息,LLM自动构建了执行工具需要用的tool_name、action等参数。

ChatCompletionMessage(
    content="It seems there was an issue with retrieving the information about Kobe Bryant's height and weight through a Google search. To calculate Kobe Bryant's BMI, we need his height and weight. Let's try to find this information by opening a browser and visiting a reliable source. I will use the BrowserUseTool to navigate to a website that provides details about Kobe Bryant's height and weight. Let's proceed with this approach.", 
    refusal=None, 
    role='assistant', 
    annotations=None, 
    audio=None, 
    function_call=None, 
    tool_calls=[        ChatCompletionMessageToolCall(            id='call_aez57ImfIEZrqjZdcW9sFNEJ',            function=Function(            arguments='{
                "action":"navigate",
                "url":"https://www.biography.com/athlete/kobe-bryant"
                }',             name='browser_use'),             type='function')]
)

think后续逻辑

  • think()后续的逻辑比较简单,主要是更新memory(memory存储单位是message),最后在100行附近的逻辑,基于self.tool_choices等参数的设置和LLM返回的工具列表,输出bool类型结果。

  • 同时,需要被调用的工具会被记录到self.tool_calls这个列表中,后续的act()会执行对应的工具。

Act()

  • 输入:同think(),不需要输入。

  • 输出:results,根据工具结果构建的一个字符串。

img_v3_02kp_44e6894bd91540ec82dc03c8e3e970bg.png

  • 这个函数比较简单,主要是调用execute_tool()函数。

Execute_tool()

img_v3_02kp_030fab99df154e819a61d3ff3bed5aeg.png

该函数会调用Tool类提供的接口execute()。

  • Tool类接口会在后面介绍。

同时,对于预设定的special tool,会self._handle_special_tool(name=name, result=result)进行特殊处理。

  • 当前的special tool 只有一个Terminate工具,特殊处理就是设置Agent的状态为AgentState.FINISHED,结束对话。

工具相关

我们在之前介绍了MCP相关的概念,如下图所示:

img_v3_02kp_841aa8ccb6d74423a435decd316bc3bg.png

事实上,OpenManus也是基于MCP的,OpenManus的tool相当于MCP server,根据MCP协议,我们只需要定义tool类支持的方法和参数等,每次注册一个新工具,根据父类override一个子类即可。

那我们首先要了解父类都定义了什么参数和方法,也就是OpenManus/app/tool/base.py定义的Basetool类。

Base Tool

img_v3_02kp_3a61d2518cb343539aad1dd28cd6686g.png

可以看出,代码很简单,每个tool包含的参数为:name、description(提供给LLM看的,对工具的介绍)、parameters(执行工具时要用的参数)。

同时,一个tool支持的方法有execute()和to_param()。

  • execute()用于执行具体的逻辑,每个子类需要override这个方法

  • to_param()将工具调用的结果结构化输出。

当然,这里还有一个python关键字__call__,这个关键字很简单,定义了__call__,该类的实例对象可以像函数一样被调用。

工具JSON

可以根据OpenManus预定义的工具json简单了解一下,每个工具执行时需要的参数。

[
  {
    "type": "function",
    "function": {
      "name": "python_execute",
      "description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.",
      "parameters": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "The Python code to execute."
          }
        },
        "required": ["code"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "google_search",
      "description": "Perform a Google search and return a list of relevant links.\nUse this tool when you need to find information on the web, get up-to-date data, or research specific topics.\nThe tool returns a list of URLs that match the search query.\n",
      "parameters": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "(required) The search query to submit to Google."
          },
          "num_results": {
            "type": "integer",
            "description": "(optional) The number of search results to return. Default is 10.",
            "default": 10
          }
        },
        "required": ["query"]
      }
    }
]

工具示例——google_search

OpenManus项目在OpenManus/app/tool中定义了bash工具、浏览器工具、谷歌搜索工具等,这里简单看一下谷歌搜索工具。

当然,国内可能比较难使用谷歌搜索,OpenManus社区也有大佬提供了baidu、bing等搜索引擎工具。

img_v3_02kp_970ea2580aca4c8980980b7f28db476g.png

可以看出,代码很简单,主要做了两件事。

  • 定义工具参数:name、description、parameters。

  • 定义execute:基于googlesearch库提供的函数进行搜索并返回。

五、总结

OpenManus的代码介绍到这里,主要是介绍一下核心代码,同时,原作者写了planning部分的代码但暂时没有应用到项目中,笔者也没有介绍。如果想对该项目有更进一步的了解,请大家查看github上提供的源码。而且,作者还是非常积极的,每天会有十几个commit。

同时,读者可以简单本地部署玩一下OpenManus,通过几个prompt,就可以知道该项目还是停留在**“玩具阶段”,比如笔者测试了一下,当询问“计算一下科比的BMI?”,OpenManus可以很准确的实现谷歌搜索****——浏览器访问——python计算**这个过程。但如果询问“计算科比、梅西的BMI并排序?”,无论我改写了几次prompt,OpenManus都没有给我满意的回答。

此外,无论是在工具参数信息、还是prompt、memory管理中,都可以看到agent应用大模型token消耗量巨大,即使我们不考虑token成本,但大模型的上下文仍然是有限的,这种资源消耗也会直接导致模型在处理多步骤任务时面临信息截断的风险 —— 早期的关键信息可能因上下文溢出而被丢弃,进而引发推理链条的断裂。更值得警惕的是,当模型试图在有限的上下文中 “脑补” 缺失的信息时,往往会产生与事实不符的幻觉。

鉴于此,尽管 OpenManus 展示出了利用工具链解决复杂问题的潜力,不过距离成为一个实用、高效且稳定的生产级人工智能助手仍有很长的路要走。未来,开发者们或许需要在优化工具使用逻辑、提升多任务处理能力、降低大模型 token 消耗以及增强上下文管理等方面进行深入探索与改进。同时,对于普通用户而言,在体验这类项目时,也应该保持理性和客观的态度,既看到其创新性和趣味性,也认识到其当前存在的局限性。希望在技术的不断迭代和完善下,OpenManus 以及类似的项目能够早日突破现有的瓶颈,真正为人们的工作和生活带来实质性的帮助。

往期回顾

1. 得物技术部算法项目管理实践分享

2. 商家域稳定性建设之原理探索|得物技术

3. 得物 Android Crash 治理实践

4. 基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术

5. LSM-TREE从入门到入魔:从零开始实现一个高性能键值存储 | 得物技术

文 / 汉堡

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

使用Trae从0到N实现一个小程序的前端开发-使用感受:像一个“眼神清澈的大学生”

2025年3月27日 11:17

引言

近期,AI-Coding的兴起引发了广泛关注。本文将详细介绍如何使用Trae平台,从零开始实现一个内容交互类小程序的全过程。该小程序包含首页、质询、我的三个模块,后端代码由作者自行编写,而前端代码及接口交互则完全借助AI完成。整个搭建过程耗时3天,以下将详细分享实现步骤及个人使用感受。

图片.png

Trae安装及使用

Trae的安装过程简便,用户可根据自身系统选择合适的安装包进行傻瓜式安装。官方内置多个模型,同时支持自定义模型。本文使用的是DeepSeekR1模型,其他微调过的开发模型可能会带来更佳效果。

图片.png Trae官网下载地址:www.trae.com.cn/download

开发过程

Trae的AI对话框提供chat和builder两种模式。chat模式用于回答用户问题,而builder模式则可根据用户需求执行操作,代替用户进行编码。鉴于作者已有明确的最终效果预期,因此采用分阶段对话的形式,逐步让AI实现小程序功能。

第一步:搭建基本框架

通过与AI对话,要求其搭建一个包含首页、质询、我的三个tab模块的微信小程序基本框架。AI不仅生成了Wxml、Wxss、JS代码,还尝试引入图标静态文件,但效果不佳。最终,作者自行寻找并替换了图标。

图片.png

第二步:实现首页功能
要求AI实现首页上方的banner图模块。经过两次对话后,AI完全满足了作者的需求,包括CSS样式及请求后端接口获取数据。

第三步:实现专栏模块
给出要请求的接口及接口返回的数据格式,要求AI在中间掺杂业务逻辑。经过多轮对话,整体实现效果良好。

后续过程中,作者与AI不断对话调试,逐步完成各个模块。整体体验良好,最终仅用三天时间就实现了小程序的所有功能。

以下为AI生成的部分代码 其中包括了分页功能和一些简单的日期转化相关的代码

Page({
onShow() {
const app = getApp()
app.httpRequest({
url: '/api/applet/member/login/getApp',
method: 'GET',
data: { appCode: app.globalData.appId },
success: res => {
if (res.data.code === 200) {
wx.setNavigationBarTitle({
title: res.data.data.name
})
const ossUrls = res.data.data.appHomeImg ? res.data.data.appHomeImg.split(',') : [];
this.setData({ bannerImages: ossUrls });
}
}
})
},

data: {
articleGroups: [],
isLoading: false,
latestArticles: [],
pageNum: 1,
pageSize: 5,
hasMore: true
},

/**

* 页面的初始数据

*/
onLoad(options) {
const app = getApp();
this.setData({
appBulletin: app.globalData.appBulletin || ''
});
this.loadArticleGroups();
this.loadLatestArticles(); // 新增数据加载调用
},
  
loadArticleGroups() {
const app = getApp();
app.httpRequest({
url: '/api/applet/member/articleGroup/queryAll',
method: 'GET',
data: { appCode: app.globalData.appId },
success: res => {
if (res.data.code === 200) {
this.setData({
articleGroups: res.data.data,
isLoading: false
});
} else {
wx.showToast({ title: res.data.message || '数据异常', icon: 'none' });
this.setData({ isLoading: false });
}
}
});
},


loadLatestArticles() {
const app = getApp();
console.log('执行条件检查:', {
isLoading: this.data.isLoading,
hasMore: this.data.hasMore,
pageNum: this.data.pageNum,
pageSize: this.data.pageSize
});

if (this.data.isLoading || !this.data.hasMore) return;
this.setData({ isLoading: true });
app.httpRequest({
url: '/api/applet/member/article/queryTop',
method: 'GET',
data: {
appCode: app.globalData.appId,
pageNum: this.data.pageNum,
pageSize: this.data.pageSize
},

success: res => {
console.log('请求参数:', {
appCode: app.globalData.appId,
pageNum: this.data.pageNum,
pageSize: this.data.pageSize
});
console.log('接口响应:', res.data);
if (res.data.code === 200) {
const newArticles = res.data.data.records.map(item => ({
...item,
createTime: item.createTime.split('T')[0]
}));
const newPageNum = this.data.pageNum + 1;
const newHasMore = newPageNum <= res.data.data.totalPage;
this.setData({
latestArticles: [...this.data.latestArticles, ...newArticles],
hasMore: newHasMore,
pageNum: newPageNum,
isLoading: false
});
}
},
fail: err => {
console.error('接口请求失败:', err);
wx.showToast({ title: '加载失败,请重试', icon: 'none' });
this.setData({ isLoading: false });
}
});
},

 

/**

* 页面上拉触底事件的处理函数

*/

onReachBottom() {

console.log('this.data.hasMore:', this.data.hasMore);

if (this.data.hasMore) {

console.log('触底加载更多数据');

this.loadLatestArticles();

}

}

使用感受

在使用期间尤其是在实现分页的过程中经过多次对话,总是有BUG,都差点忍不住自己改了,总体来说AI实现的代码风格和代码质量,大致相当于一个拥有很好的知识储备,但是眼神清澈的大学生,能够高效完成基本的编码和开发任务。

然而随着社会和科技的不断发展,程序员这个职业将不断的变的更加平凡。所以大家仍需保持开放态度,积极的去学习新技术,以应对未来的挑战。

2026 年上车,宝马和阿里通义合作定制新一代「出行伴侣」

作者 芥末
2025年3月27日 08:41


与在采用纯电驱动这件事上的犹犹豫豫和来回拉扯不同,各家汽车巨头在座舱智能化这件事情上倒是相当坚定,最近的大动作也不少。

继奔驰在 CLA 发布会上宣布将 计划把 AI 模型接入座舱系统之后,宝马在 3 月 26 日也宣布和阿里通义合作,争取在 2026 年把「宝马 AI 定制引擎」搬上车。

你可能会觉得,「AI 大模型」咱们又不是没见过,装一个 AI 应用在车机上,整点语音对话和文生图之类的功能,就能叫 AI 座舱了?

但宝马这次确实追上了业界前沿,打算一次性把「人工智障」进化到「AI 智能体」,也就是最近正火的 AI Agent。

这是推动AI与先进制造业融合的一次创新探索。AI 的想象力在于改造物理世界,把 AI 能力转化为千行百业的生产力。

宝马和阿里这次合作的定制 AI 引擎能够在意图捕捉、指令解析、模糊语义理解及逻辑推演能力上有所提升,支持连续指令自然交互,通过推理以更拟人化的方式来执行更多的复杂操作。

宝马举了一个例子来说明他们对这个定制引擎的期望,例如:「晚上要请爸妈和几个亲戚吃饭,推荐个朝阳公园西门附近、能地面停车、人均 200 左右、口味清淡、口碑好的餐厅吧。」然后系统就能综合实时路况、充电桩分布、景点口碑、用户偏好等多重信息,直接给你几个答案,然后导航过去。

说实话,哪怕在手机上,现在能做到这一点的 AI 模型也不多,但是各家目前都在朝着这个方向努力,真能实现的话确实能在相当程度上提升座舱的交互体验。

当然,这个 AI Agent 也希望能让你在驾驶时更加得心应手。通过依托自然语义大模型训练,在人机沟通上更自然、更贴近人与人之间的交互方式。

BBA 们的语音助手之前受到过很多吐槽,主要是识别率和能实现的操作都十分有限,常常出现「我说城门楼子,你说胯骨轴子」囧境。

当然这个 AI Agent 也不全都是被动执行,宝马说希望让它有一些主动关怀的能力,通过模糊语义理解能力与记忆学习能力来感知和记忆你的偏好。

比如当感知到你要搭早班航班赶往机场的时候,可以主动问候并播放一些你喜欢的歌曲。就不用用户自己再在音乐软件上翻来翻去,等音乐找到了,可能心情也已经所剩不多了。

在 4 月份的上海车展上,宝马就打算让「用车专家 Car Genius」和「出行伴侣 Travel Companion」两大 AI Agent 在中国用户面前亮相,到时候董车会一定去体验一下。

在这里,我们正以中国速度,携手中国力量,将前沿技术融入宝马创新体系,打造更懂中国消费者的智能驾驶乐趣。与阿里巴巴集团长期深入的探索合作就是最佳力证。

从这次合作我们也观察到了一个新的趋势,那就是中国的汽车产业链正在从「学习者、后来者」的身份上慢慢成长起来,转变向平等对话,甚至在某几个优势领域能够引领趋势。

智能座舱或许是最明显一个部分,国内新势力在这部分的竞争相当激烈,车机功能的丰富度和流畅性现在已经成为了消费者购车决策的一个关键指标。

在其它领域也有相同的趋势,例如小鹏和大众开始共同开发新的电子电气架构,零跑和玛莎拉蒂的母公司 Stellantis 合作开发新的电动车型,以及奥迪新 A5L 全面搭载华为智驾等等。

车企们现在想造出有竞争力的新车,使用本土供应商已经是大势所趋。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


为了投入AI的怀抱,将Nextjs项目从Vercel迁移到了CF,结果是好的,过程是痛苦的

2025年3月26日 18:01

天下苦Vercel久矣

这应该是每个使用Nextjs开发一直想说的话,我在这里帮大家喊出来 🎺。

很多人使用Nextjs都直接部署到Vercel上,方便又快捷,一键部署爽歪歪,不过说实话,没有特别的要求它是真的舒服。

但是!!!

如果你想要做AI项目而且是白嫖党,vercel是有限制的,接口默认10s,最高60s的返回,一些久一点的接口,就直接废了,升级高达20美金,这是钱的问题吗?这是命!!!

最近在研究一些AI集成,就被这个问题卡住了,最后发现了赛博菩萨CloudFlare的存在,决定将项目部署到CF上。

image.png

蛋疼的edge runtime

因为项目的技术栈是supabase、prisma、nextjs,所以想要迁移到cf上需要对项目进行几个改造。

1.首先是nextjs的改造。

安装对应的cf库

pnpm add -D @cloudflare/next-on-pages

然后添加一个项目信息文件,2. wrangler.toml

name = "filetohtml"
compatibility_date = "2025-03-22"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"

然后在nextjs.config.js添加下面的内容

import { setupDevPlatform } from "@cloudflare/next-on-pages/next-dev";

if (process.env.NODE_ENV === "development") {
  await setupDevPlatform();
}

\\ ...你原本的内容

2.prisma和supabase的结合使用问题,因为prisma链接postresql时使用了pg相关的库,在edge runtime时是无法使用的,所以需要将其改为所有数据库调用都使用supabase.js。

这也是一个大工程,supabase是支持edge运行的,那么我们要用supabase作为数据库调用工具,不能使用prisma orm了。


import { createClient } from "@supabase/supabase-js";  
  
export function getSupabaseClient() {  
  const client = createClient(  
    process.env.SUPABASE_URL || "",  
    process.env.SUPABASE_ANON_KEY || ""  
  );  
  
  return client;  
}

3.nextjs的问题,根据文档来弄,需要将所有的route和page添加代码。

export const runtime = "edge";

指定在edge上运行。

4.添加命令行

"pages:build": "pnpm dlx @cloudflare/next-on-pages@1",
"preview": "pnpm pages:build && wrangler pages dev",
"deploy": "pnpm pages:build && wrangler pages deploy"

因为我的项目是用pnpm下包的,所以也要使用pnpm部署,这个要切记,用cf默认的部署命令是npm,有可能会出现部署问题。

部署设置按照图来设置。

image.png

舒服的cf

部署到cf之后,调用ai接口就没有限制问题了。

cf还有很多免费的功能,dns、安全、d1数据库等等一大堆功能,满足大部分的需求。

image.png

最后

附上这周集成ai功能做的一个小项目---文件生成html

image.png

❌
❌