这个项目是从一个很具体的问题开始的本地已经跑了一堆工具有脚本、有数据库、有定时任务但它们之间互不通信每次想做点跨工具的操作都得手动连好几趟。后来接触到 AI Agent 的概念第一反应是这正好能解决我的问题。但市面上的 Agent 框架要么默认绑云端大模型要么跑起来就是一套重得要命的微服务我只需要一个能常驻本地、通过自然语言调度已有工具的小东西。于是我决定自己写一个技术栈选了 Kotlin 和 Ktor项目开源主打本地优先。这篇文章就把我做这个项目过程中的思路、架构和踩过的坑完整写出来。1. 项目背景与需求拆解1.1 为什么坚定选择“本地优先”做这个项目之前我其实先试过两三个现成的 Agent 框架体验都很微妙。功能确实强大但依赖链特别长启动时要拉起一堆服务配置里写满了各种云端模型的 API Key。在本地环境里我就想让它帮我处理文件、查数据库、跑几个维护脚本它却动不动把数据送到云端去理解这在隐私和响应速度上都很难接受。本地优先的意思不是拒绝所有云端能力而是默认一切都在本机闭环运行模型推理走本地推理引擎工具调用走本机 shell、文件系统和数据库Agent 的核心状态也只存在本地。好处非常直接。一是隐私边界清晰数据不出机器不需要为“哪些能传云端、哪些不能”反复纠结二是延迟可控本地模型的首 token 延迟虽然比不上大厂 API但省去了网络抖动和排队整体体验反而稳定三是离线可跑断网环境下开发调试完全不受影响。对个人项目和内网工具来说这三条几乎就是刚需。当然本地优先也有代价。最大的代价就是模型能力上限被本地硬件锁死了。我的机器是普通开发本只能跑 7B、13B 级别的量化模型复杂逻辑推理和长文本理解确实不如云端大模型。所以我在设计 Agent 时做了一个妥协把 Agent 做成模型无关的运行时本地模型负责大部分常规任务的意图理解和工具选择如果将来想在某个环节接入更大规模的云端模型也能通过同一套接口切换只是默认不开启。这样既保住了本地的默认路径又留了升级的口子。1.2 技术选型为什么是 Ktor选 Ktor 的原因可以拆成三层。第一层是我本来就在 Kotlin 生态里写后端服务Ktor 的协程模型和 Kotlin 语言配合得非常顺几乎感觉不到异步切换的割裂感。第二层是 Ktor 本身足够轻它不是一个全家桶框架核心只是一个 HTTP 引擎路由、序列化、WebSocket 这些能力都以插件方式按需加载。对 Agent 这种需要快速启动、常驻命令行或桌面环境的程序来说这一点很关键——我不希望为了一个 Agent 背上 Spring Boot 那么重的运行时。第三层是被它的 Server 和 Client 一体性吸引。Ktor 同时提供服务端和客户端两边共用同一套协程和序列化 API。Agent 对外要提供一个 HTTP 接口供交互对内又要作为 HTTP 客户端去调用本地模型服务比如 Ollama 的 API。用同一个框架写这两段代码类型转换、错误处理、超时配置都是同一套心智省掉了很多跨框架对接的麻烦。也考虑过直接用 Node.js 或者 Python 写但最终放弃的原因很实际我希望 Agent 核心是编译型产物发布时能打成一个可执行 jar 或者 native 包不要求用户装 Python 环境也不要求对方维护一堆 node_modules。Kotlin/Native 目前对我这种需要用到 JVM 生态库的场景还不够顺所以现阶段以 JVM 为主Ktor 正好能把服务端、客户端、序列化、协程这些粘合在一起。1.3 Agent 到底要解决什么问题我最初对 Agent 的定义非常朴素给自然语言一个入口让它能替代我完成一串固定的、跨工具的操作。比如我说“把昨天日志里所有 ERROR 行统计一下然后按数量排序写进 report.md”过去我要么写一段临时脚本要么手动 grep、sort、重定向现在我希望 Agent 自己拆解步骤调用 shell、文件读写、文本处理最后把结果写回文件。但朴素需求拆开之后会发现背后藏着几个绕不开的设计点。第一是意图解析模型需要把用户的话转成可执行的工具调用序列而不是直接输出一段泛泛而谈的文字。第二是工具抽象每个工具必须有清晰的入参和出参模型才知道什么时候该调哪一个。第三是状态管理多轮对话里用户可能随时追加条件“刚才说的那个文件”到底指哪个文件需要 Agent 有记忆和引用能力。第四是错误恢复工具执行失败是常态Agent 不能直接崩溃它应该把错误信息反馈给模型让模型换一条路径重试。这些点单独拿出来都不难难的是它们要在一个长生命周期的进程里协同工作。所以我没有把项目做成一个玩具级的“聊天加工具”而是从一开始就按一个可扩展的运行时来设计模型是可替换的工具是可插拔的存储是可迁移的。Ktor 在这里不是出风头的角色它负责把运行时提供一个稳定的 HTTP 外壳但整个内核架构与框架解耦这也是我能持续迭代的主要原因。2. 整体架构与数据流设计2.1 模块怎么划分整个项目按职责分成四层。最外层是接入层负责承接用户输入和输出交互形态可以是一命令行程序、一个 REST 接口、一个 WebSocket 连接甚至未来可以做桌面窗口这一层只做协议转换不碰任何 Agent 逻辑。第二层是 Agent 引擎层包含会话管理、上下文组装、模型调用循环、工具决策这几个核心模块它们共同决定了一次用户请求如何被理解、拆解和执行。第三层是工具层每个工具就是一个独立的执行单元接收 JSON 参数返回 JSON 结果内置在本地环境执行具体操作。最底层是基础设施层包括存储、配置、日志和模型客户端所有上层模块都只依赖这层提供的接口不直接写死具体实现。分层的好处不用多说最直接的收益是测试容易。我可以把工具层单独拿出来不启动模型就验证所有工具逻辑是否正确也可以把 Agent 引擎层单独跑用一个模拟模型返回固定的工具调用验证状态流是否正常。对个人开源项目来说这种可测试性太重要了因为没人帮你做集成测试你只能靠分层把出错范围尽量缩小。数据流走的是经典的单轮循环加异步落盘。用户在接入层说了一句话请求进入 Agent 引擎引擎把历史上下文和当前消息拼成模型提示词发给本地模型模型返回一个结构化结果可能是普通文本回复也可能是工具调用指令。引擎解析结果如果是工具调用就把参数交给对应工具执行拿到工具结果后再拼接成新一轮上下文继续发给模型直到模型给出最终文本回复。整个过程是阻塞式循环但对单用户本地场景完全够用也最容易 debug。2.2 工具调用的核心机制工具是 Agent 和真实世界之间的桥梁所以我花了很多心思设计它的描述方式。每个工具在注册时都提供一个 JSON Schema 风格的描述包括名称、一句话说明、参数对象定义以及是否会产生副作用。模型根据这份描述决定要不要调、调哪个、传什么参数。工具描述写得好不好直接影响模型调用的准确率这一点怎么强调都不过分。实际工程里我给工具做了五个字段name、description、parameters、sideEffects、handler。name 必须和代码里的函数名语义一致description 要写清楚这个工具能做什么、不能做什么parameters 是严格的 JSON SchemasideEffects 标记是否只读操作handler 是真正执行逻辑的 Kotlin lambda。注册工具就像这样fun fileTools(): ListToolSpec listOf( ToolSpec( name read_file, description 读取指定文本文件的内容path 为绝对路径或相对当前工作目录的路径, parameters mapOf( type to object, properties to mapOf( path to mapOf(type to string, description to 文件路径) ), required to listOf(path) ), sideEffects false, handler { params - readFileContent(params[path] as String) } ), ToolSpec( name write_file, description 把 content 写入指定文件若文件存在则覆盖注意该操作不可逆, parameters mapOf( type to object, properties to mapOf( path to mapOf(type to string), content to mapOf(type to string) ), required to listOf(path, content) ), sideEffects true, handler { params - writeFileContent(params[path] as String, params[content] as String) } ) )模型返回的调用指令用 JSON 表示格式大致是{tool: read_file, arguments: {path: /tmp/a.txt}}Agent 引擎解析后直接查注册表把 arguments 映射到 handler 的入参。我在设计上刻意要求工具执行结果必须序列化成字符串返回给模型因为本地模型的上下文能力有限一个工具如果产生了超大输出最好由工具自己做好摘要而不是把几十万字节直接塞回对话上下文。这里有个容易被忽略的细节工具描述里的 description 不能写得太口语化也不能太含糊。我一开始写“读取文件”四个字模型经常不知道该传绝对路径还是相对路径。改成完整句并加上示例后误调率明显下降。模型本身不笨但它没有读代码的能力描述就是它对工具的全部认知。2.3 上下文与记忆怎么管理上下文管理直接决定了 Agent 对话的连续性和模型有效输入长度。本地模型的窗口普遍不大我日常用的模型上下文在 8K 到 32K 之间但 7B 量化模型真正能高质量理解的内容远低于窗口上限塞满一半就会开始胡言乱语。所以我的策略是三层记忆结构。会话级记忆负责记录多轮对话的原始消息列表这是最完整的存档。摘要级记忆负责维护一个不断压缩的对话摘要每当原始消息条数超过阈值就调用模型对已有对话做一次总结把总结作为下一轮上下文的前缀。工具结果记忆比较特殊它只保留当前轮次工具执行后的关键输出摘要上一轮的工具结果不会永久留在上下文里避免上下文被历史工具输出撑爆。组合起来的效果是每轮实际发给模型的上下文等于系统提示词、会话摘要、最近几轮消息、当前工具输出摘要。系统提示词里我会明确告诉模型“你是一个运行在本地环境的 AI Agent你的回复必须简洁优先调用工具完成任务”。摘要压缩和最近消息截断都做了但我保留了“完整历史落盘到本地文件”这条兜底路径任何一轮对话都能回溯不用怕摘要丢细节。实现摘要压缩的代码并不复杂核心逻辑就是定期把历史消息丢给模型让它生成一段不超过 N 个 token 的对话摘要。这里有个隐患压缩本身也是一次模型调用如果摘要 prompt 写得不好模型可能会漏掉关键信息。我现在的方法是先把历史消息里的工具调用记录高亮出来比如标注“用户要求排序文件时使用了 sort_files 工具”让模型在摘要时优先保留这些动作性信息。效果比直接甩原始消息好很多。2.4 本地模型接入层模型接入层是 Agent 的一个适配器我没有把某个具体模型厂商的 SDK 绑死在核心代码里。对外只定义一个接口叫ModelProvider它要做的事情听起来很简单给你一串消息返回模型输出结果。但这个简单接口背后要处理的事情很多包括流式输出支持、超时重试、错误码分类、上下文截断。本地推理我主要对接 Ollama。为什么选它而不是直接用 llama.cpp 的绑定原因很现实Ollama 已经处理好了模型格式转换、GPU 加速、常驻后台进程这些琐碎事我只需要通过 HTTP 接口跟它通信而且它的 OpenAI 兼容接口让我随时可以把 provider 切到远端模型。当然这不是唯一选项我也保留了对 llama.cpp server 的适配潜力但首版先把最省力的路径打通。接入层还要负责处理模型返回的格式。我要求模型输出必须是严格 JSON但本地小模型的 JSON 稳定性没那么好经常多一个尾逗号或者少一个引号。所以我在接入层写了一个宽容解析器先尝试标准解析失败后再做一轮轻量修复比如去掉注释、补全引号、提取最外层 JSON 块。这个解析器救了我很多次没有它Agent 的工具调用流程根本跑不起来。Ktor 的 HttpClient 在这里派上了用场。本地模型地址是http://127.0.0.1:11434客户端配置了连接超时 5 秒、请求超时 120 秒这样模型卡死时 Agent 不会无限等下去而是能报错并把错误信息纳入下一轮上下文让模型自己决定是重试还是告知用户。3. 实操过程与关键实现3.1 从零搭建 Ktor 项目骨架项目用 Gradle 构建Kotlin 版本用 2.0 以上Ktor 版本用 2.3 系都是当前稳定线。依赖不算多核心是ktor-server-core、ktor-server-netty、ktor-server-content-negotiation、ktor-client-core、ktor-client-okhttp加上kotlinx-serialization-jsonJSON 解析直接用官方序列化库。我特意没加数据库框架先用一个简单的 JSON 文件持久化会话后面再上 SQLite。启动入口就是一个标准 Ktor embeddedServer先起 HTTP 服务再初始化 Agent 引擎和工具注册表fun main() { val toolRegistry ToolRegistry() toolRegistry.register(fileTools()) toolRegistry.register(shellTools()) toolRegistry.register(dbTools()) val modelProvider OllamaModelProvider( baseUrl http://127.0.0.1:11434, model qwen2.5:7b-instruct-q4_K_M ) val agentEngine AgentEngine( toolRegistry toolRegistry, modelProvider modelProvider, storage JsonFileSessionStorage(Path.of(sessions)) ) embeddedServer(Netty, port 8765) { install(ContentNegotiation) { json() } routing { route(/api/agent) { post(/chat) { val request call.receiveChatRequest() val reply agentEngine.processMessage(request.sessionId, request.message) call.respond(reply) } get(/tools) { call.respond(toolRegistry.describeAllTools()) } } } }.start(wait true) }这段代码基本就是项目的全部骨架了。routing里暴露了两个 API一个给用户发消息一个给前端或者调试工具查看所有已注册工具的描述。把toolRegistry单独拎出来还有个好处我可以在启动时打印一份注册表快速核对每个工具的 JSON Schema 有没有写错。3.2 Agent 主循环怎么实现Agent 引擎的processMessage方法是整个项目的核心它实现了一个标准的 ReAct 循环思考、调用工具、观察结果、再思考。我用一个最大迭代次数来控制循环默认 8 轮防止模型陷入工具调用的死循环。每一轮迭代都向模型发送当前累积的上下文模型如果返回tool_call就执行工具并把结果附加到上下文继续如果返回最终回复直接跳出循环。实现里最关键的一点是把上下文构造成一个消息列表而不是简单的字符串拼接。系统消息、用户消息、工具结果消息分别用不同角色标识这样模型能更准确地理解哪部分是历史对话、哪部分是工具输出。给模型的消息列表大概是这个形态data class LlmMessage( val role: String, val content: String )构造上下文时我会依次放入系统消息、摘要压缩消息、最近 N 条历史消息、当前用户消息、以及最近一轮的工具结果消息。历史工具结果我不全放进去只放当前轮次的最近一次工具输出。原因前面说过小模型窗口不够塞太多历史工具输出反而会干扰判断。循环里最重要的一个安全阀是“工具结果必须被校验”。工具返回之后引擎先检查返回类型是否可序列化、有没有超过长度限制再把它转换成消息。如果工具本身抛了异常我不会让异常直接冒泡到 HTTP 接口而是捕获后生成一条错误消息内容为“工具执行失败错误是 xxx请根据错误调整参数后重试或向用户说明”。这样模型有机会自我纠错比如路径不存在它可能换个目录再试一次。3.3 工具注册与执行器设计工具执行器我把它做成了同步调用这样 Agent 循环里每一步的时序都清清楚楚不用为了并发而引入额外的复杂度。但工具本身也分快慢两种快工具比如读文件、查内存表几百毫秒内返回慢工具比如执行一个长时间备份脚本可能要几分钟。对于慢工具我没有用异步回调而是直接在调用前给模型提示“该工具运行时间较长”并让 Agent 设置请求级别的超时上限。工具注册表内部就是一个ConcurrentHashMapString, ToolSpec注册时做重名校验。每个 ToolSpec 的 handler 接受MapString, Any?作为参数内部再手动转换类型。这里我没有用反射自动绑定因为 JSON Schema 的类型和 Kotlin 类型之间的映射没那么简单手动转换虽然啰嗦但可读性和可控性都更好。一个典型的手写工具实现长这样ToolSpec( name list_processes, description 列出当前系统中占用 CPU 最高的 10 个进程适合排查资源占用问题, parameters mapOf( type to object, properties to emptyMapString, Any(), required to emptyListString() ), sideEffects false, handler { _ - val result ProcessBuilder(ps, -eo, pid,pcpu,pmem,comm, --sort-pcpu) .redirectErrorStream(true) .start() .inputStream.bufferedReader().readText().lineSequence().take(11).joinToString(\n) mapOf(processes to result) } )工具返回统一用MapString, Any?最后序列化成 JSON 字符串给模型看。设计上我没有让工具直接返回 Kotlin 对象因为 JSON 化之后才能保证模型看到的文本是稳定格式不然不同工具返回不同结构模型会无所适从。3.4 本地存储与配置管理存储这一块我虽然前面说先用 JSON 文件但实际项目里我还是加了 SQLite因为会话记录一多全量读写 JSON 文件就变得很笨重。SQLite 通过 JDBC 驱动接入Ktor 不关心存储层Agent 引擎也不关心只有存储实现自己知道数据放哪。这样后续想换成其他数据库只需要重写一个 Storage 实现。存储内容分两张表一张存会话元数据一张存消息记录。会话表字段包括 session_id、create_time、摘要文本、消息条数。消息表包括 id、session_id、role、content、timestamp。每次收到用户消息和每次模型回复都会异步写入消息表。异步写入很关键因为如果同步写模型生成响应后还要等磁盘 IO 才能返回体验会差不少。配置管理没有用 Properties 文件那种传统方式我直接读环境变量加一个可选的agent.conf文件。配置项包括模型地址、模型名称、最大迭代次数、上下文保留轮数、数据存储路径。默认值都写在代码里用户不配也能跑。这个设计是我从很多开源项目里学来的本地工具默认零配置启动能用再谈个性化。3.5 命令行客户端与调试方法服务端起来以后我用 Ktor Client 写了一个极简命令行客户端方便直接在终端里和 Agent 对话。客户端逻辑很简单循环读 stdin把输入 POST 到/api/agent/chat打印返回结果。它的作用不只是测试也是我平时真正使用这个 Agent 的主要入口。fun main() runBlocking { val client HttpClient(OkHttp) { install(ContentNegotiation) { json() } } val scanner Scanner(System.in) var sessionId UUID.randomUUID().toString() while (true) { print( ) val line scanner.nextLine() if (line /quit) break if (line /newsession) { sessionId UUID.randomUUID().toString(); continue } val response client.post(http://127.0.0.1:8765/api/agent/chat) { contentType(ContentType.Application.Json) setBody(ChatRequest(sessionId, line)) }.bodyChatReply() println(Agent: ${response.reply}) } }调试工具调用时我还加了一个/api/agent/debug接口返回最近一轮的完整上下文消息列表包括每轮工具调用和结果。这个接口是排查问题的神器。模型如果连续几次选错工具看一眼上下文就知道是描述出了问题还是摘要压缩丢了关键信息。4. 常见问题与排查技巧4.1 协程并发下的状态污染Ktor 本身就是协程驱动的但 Agent 引擎内部如果用可变状态就必须小心并发。我最开始直接把历史消息列表存在内存里每个 session 一个MutableListLlmMessage在多用户并发请求下两条消息可能会互相覆盖甚至导致上下文顺序错乱。解决方法是在 AgentEngine 里维护一个ConcurrentHashMapString, AgentSession每个 session 对象内部所有可变字段都通过synchronized保护或者干脆用不可变数据加copy更新。我选择了后者因为 Kotlin 的 data class 做局部更新很方便而且不容易写出隐蔽的竞态。实际效果是单个 session 的请求是串行处理的但不同 session 之间可以并行吞吐够用且逻辑安全。排查这类问题有一个笨办法但特别好用在每次进程消息的前后打印 session 的消息条数和最后几条消息的 hash如果出现高并发下 hash 对不上基本就是有并发写入。4.2 模型返回格式不稳定本地小模型的 JSON 输出经常不稳定这是所有接本地模型做 Agent 的人都会遇到的问题。除了前面提到的宽容解析器我还用了两个土办法。一个是在系统提示词里给出“你只能输出如下格式的 JSON”的示例并且示例写得非常具体另一个是在解析失败后做一次“纠错重试”把解析错误信息返回给模型让它重新生成一次。重试还能解决另一种情况模型本来该调用工具却直接回答了用户没有走工具逻辑。这种时候我会额外追加一句提示“如果用户请求需要操作本地文件或执行命令你应当调用对应工具而不是直接猜测结果。” 这一招对防止模型胡编数据非常有效。最严重的一次问题是模型把工具参数里的路径字符串写成了不带引号的裸词导致 JSON 直接破损。我最后在宽容解析器里加了一个规则如果发现某个位置的值明显是字符串但这个值没有引号包裹就自动尝试修复。这个规则有风险但实际命中率很高能救回大约三分之一原本会失败的解析。4.3 上下文溢出和摘要丢失上下文溢出是最常见又最影响体验的问题。我一开始把最近 20 轮历史全放进上下文7B 模型直接表示看不懂了。后来调成“最近 6 轮 摘要”效果立刻改善。但摘要本身也坑过我有一版摘要 prompt 太笼统模型把用户的原始诉求给摘要没了Agent 开始回答一些和目标无关的内容。我发现一个有价值的技巧摘要时不要让模型讲故事而是让它列要点。比如“用户要求1. 分析日志 2. 统计错误 3. 写入报告。已执行读取日志、调用统计工具”。这种结构化的摘要即使信息有删减关键动作不会丢。现在我的摘要压缩指令里直接要求输出固定格式模型照做的概率高很多。如果你的模型窗口实在太小连摘要都没地方放我建议在接入层做硬截断保留系统提示词和最近两轮消息其余全部丢弃。这会让多轮对话的短期记忆变差但至少每一轮都能正常返回结果。对本地工具型 Agent 来说用户更常发单轮指令短记忆的影响没那么大。4.4 打包分发的几个坑开源之后最常被问的就是“怎么跑起来”。我把 Gradle 配置成能打出 fat jar这样用户只要装好 Ollama、下载模型、放一个config.properties然后java -jar agent.jar就能运行。但打包过程中遇到两个实际问题。第一个是资源文件路径。我用Path.of(sessions)来存会话工作目录不同会导致路径不一样。对本地工具来说这可以接受但如果作为系统服务启动就必须在配置里显式指定绝对路径。现在我会在启动日志里打印当前工作目录方便排查。第二个是 JDK 版本兼容。Ktor 2.3 在高版本 JDK 上一切正常但用户如果用的是老 JDK可能会碰到 TLS 或序列化相关问题。我的建议是直接要求 JDK 17 以上并且在 README 里写明用sdkman或包管理器安装的推荐版本别指望用户自己去排查。5. 经验复盘与后续方向5.1 本地优先的收益和边界跑了大半年之后我对本地优先的收益有了更具体的感知。最明显的是调试效率我可以随时改工具代码重启 Agent整个过程不到三秒不需要等云端依赖同步也不需要担心配额被刷爆。对个人工具来说这种“随便折腾”的感觉非常重要。边界也很清晰本地模型不适合做需要大量常识或专业知识的事情。比如让它写一份行业分析报告7B 模型产出的内容结构不错但细节经不起推敲。我现在只在 Agent 的功能设计里放它擅长的任务比如本地文件整理、命令执行、状态查询、提醒管理这些都依赖工具而不是模型自己的知识模型只要做好意图理解和参数提取就行。把模型的职责限定在“调度”而不是“生成知识”本地模型就够用了。另一个边界是生态依赖。本地 Agent 再强也没法替代那些内置了云端知识库的成熟产品。所以我在设计工具时更偏爱底层的通用能力比如执行 shell 命令、读写文件、调用 curl 访问内网服务而不是为某个特定平台写死功能。这样 Agent 的通用性不会被局限在单一场景里。5.2 如果重来我会在第一天就做三件事第一我会先写一套完整的工具测试。很多工具 Bug 其实是后来才暴露的比如路径里有空格导致命令解析失败。如果从第一天就给每个工具配上固定的在/离线测试后面加新工具会轻松非常多。第二我会从一开始就用结构化日志而不是 println。等 Agent 循环复杂之后普通打印根本分不清是模型返回、工具调用还是存储写入日志时间线混乱会浪费大量排查时间。用kotlinx.datetime加上每个事件的 sessionId、环节类型、耗时问题定位效率能提升数倍。第三我会把上下文摘要策略做得更早更成熟。第一版只做了简单截断导致一旦多轮对话稍长模型就丢失早期信息用户只能反复提醒。后来补上摘要压缩后体验才稳定下来。这个功能应该是一开始就放进架构里的而不是等出了问题再补。最后分享一个很个人化的习惯开源项目如果定位是“自己用的工具”就不要过度设计。我一度想加上插件热加载、分布式部署、可视化编排这些花活但后来全部砍掉了。对于本地优先的 Agent稳定、可调试、快速启动才是核心。这个项目的演进方向也不会是变得更大而是变得更顺手——让模型的调度更准、让工具更丰富、让使用成本更低。保持这个方向慢慢迭代比我一开始设想的大而全路线走得更远。 SEO 优化官网定制响应式建站教育培训建站