@cactuscompute: Needle 最不为人知的秘密不是函数调用。它是结构化提取。长文本输入 → 有效 JSON 输出。小巧…
摘要
Needle 2 是一个开放的、45M 参数 AI 模型,用于工具调用和结构化提取,优化后可在浏览器中以 14MB 运行,并通过受约束采样保证 JSON 输出。
查看缓存全文
缓存时间: 2026/09/02 14:00
Needle 最不为人知的秘密不是工具调用,而是结构化提取。输入长文本,输出有效 JSON。仅 14MB 大小即可在浏览器中运行。JSON 架构由与工具调用相同的约束采样技术保证。
https://t.co/FH18v6a3iT https://t.co/BHU2ZdqRpC
cactus-compute/needle
来源:https://github.com/cactus-compute/needle
Needle 2
Needle 2 是一个开源的 45M 参数模型,专注于工具调用、设备操作和结构化提取。整个模型是一个 14MB 的单一二进制文件,运行时仅占用约 28MB 内存。基于我们的简单注意力网络(Simple Attention Network)研究成果构建,通过仙人掌量化(Cactus Quants)压缩至 CQ2 位,并封装在其专属引擎中。
在以下基准测试中,Needle 2 与其他小模型(如 FunctionGemma 270M、LFM2.5 230M 和 Apple FM)各有胜负,体积缩小 5 至 70 倍,使用 2 位量化对比对方的 f16 精度。
本仓库提供 Python 包:包含推理、LoRA 微调和导出功能。
pip install cactus-needle
只需描述工具,即可在 Python 中调用。推理引擎首次运行时从 Hugging Face 获取并缓存;无需额外构建,离线设备部署方案详见 doc/apis.md。
- 自包含:权重封装在单个 14MB 引擎内;无需管理独立模型文件,推理过程无网络请求。
- 简洁契约:工具调用以结构化数据返回,输入文本、输出 JSON;字节级语法根据您的架构约束每个生成的 token。
- 置信度门控:每个响应都携带经学习头校准的置信度分数;设置阈值,高于阈值直接执行,低于阈值则上报处理。
- 工具检索:声明大型工具库时,内置检索头每轮仅呈现前五项工具,且语法约束仅应用于该子集。
- 内存可控:256 token 滑动窗口配合工具作为 KV 缓存锚点,确保对话时长不影响总内存(始终约 28MB)。
权重:huggingface.co/Cactus-Compute/needle2 (https://huggingface.co/Cactus-Compute/needle2)
源码:github.com/cactus-compute/needle (https://github.com/cactus-compute/needle)
质量-体积前沿:适用于移动端及更轻量级场景
简单注意力网络
Needle 2 采用简单注意力网络(Simple Attention Network),这是我们专为紧凑型模型设计的密集架构:
- 用 Hadamard MLP 替换前馈网络
- 采用分组查询注意力(GQA)
- 集成记忆痕迹键值存储(engram key-value memory)
- 多通道超连接(multi-lane hyper-connections)
设计细节与消融实验详见论文:arXiv:2607.18363 (https://arxiv.org/abs/2607.18363)
简单注意力网络架构
每个模块包含其更新规则。其中:
x̂是四条残差流的 RMS 标准化展平结果H是正交沃尔什-哈达玛变换(固定矩阵,以 n log n 时间复杂度应用,无需读取权重)(kt, vt)行来自哈希 n-gram 表P是路由逻辑A的双随机归一化结果,通过 Sinkhorn 迭代计算a、b、g及所有 σ 门均为可学习的输入相关参数
注意力与 MLP 残差均经过三明治归一化与门控处理;记忆痕迹位点在两层被激活;解码过程受声明架构编译的字节级语法约束。
快速开始
pip install cactus-needle
运行时包不包含训练栈。使用微调或检查点导出时需添加 train 额外依赖:
pip install "cactus-needle[train]"
Needle 会读取您的工具描述来决定调用方式及参数填充,因此良好的描述是关键。
简单用法:用装饰器定义函数。签名提供参数类型,文档字符串作为工具描述,run() 方法完成闭环:模型选择调用→Needle 执行您的函数→返回结果并附加执行记录为 results。
import needle
@needle.tool
def get_weather(city: str):
"Get the current weather for a city."
return {"city": city, "temp_c": 27, "sky": "clear"}
agent = needle.Needle(tools=[get_weather])
print(agent.run("what's it like in Lagos right now?")["results"])
# [{'city': 'Lagos', 'temp_c': 27, 'sky': 'clear'}]
数据提取:从文本中提取结构化数据时,声明数据结构并调用 extract()。传入 Pydantic 模型即可获得类型化对象。
from pydantic import BaseModel
class Invoice(BaseModel):
vendor: str
total: float
due_date: str
invoice = needle.extract("Invoice from Acme Corp, $1,200.00, due 2026-09-01", Invoice)
print(invoice.vendor, invoice.total)
# -> Acme Corp 1200.0
参数描述、选项、值约束编译进解码语法、原始 JSON 架构、驱动循环的 complete() 方法、响应契约、系统提示、工具检索及置信度门控等详细信息,请参阅 doc/apis.md。
演示平台
在浏览器中体验任意模型:选择预设模板,编辑工具或提示,点击运行。后续查询会延续同一对话。
needle playground
# 基础模型,访问 http://127.0.0.1:7860
needle playground --weights my.cact
# 使用微调模型
服务器会在启动时下载并初始化模型,因此首次查询立即响应。微调这些工具按钮可通过界面运行下方微调流程,并返回可下载的 .cact 文件。
预设环境
needle.environments 提供现成的工具集:smart_home(智能家居)、media_player(媒体播放器)、productivity(生产力工具)、wearable(可穿戴设备)、kitchen_appliance(厨房电器)和 data_capture(数据采集)。每个工具集经过人工策划,其枚举值、边界和描述完美适配 Needle 的约束解码机制,并包含现成的代理程序和冻结的测试套件。
from needle.environments import smart_home
smart_home.agent.complete("dim the study lights to 30 percent")
smart_home.run_tests()
通过 python -m needle.environments.smart_home 可从终端运行测试套件。若要将环境适配到您的产品,只需替换 Literal 值(如房间、联系人、类别),保持数据结构:封闭集用枚举表示,有界数值保持范围,自由文本直接复制,工具数量控制在五个以内。完整工具集与套件契约详见 doc/environments.md。
微调
Needle 使用 LoRA 在冻结的基础模型上进行微调,并在导出时合并适配器,因此单次运行成本低廉,且微调后的模型仍是单个 .cact 文件,可直接运行于同一引擎。
工作流程包括:(可选)数据合成→LoRA 微调→构建微调版 .cact。
详见 doc/finetuning.md 了解数据集规模、损失曲线解读及问题排查。
数据格式:JSONL 文件,每行一个示例。reasoning(推理过程)为可选字段;无关示例的 answers 设为空列表。
{
"query": "dim the kitchen to 10",
"tools": [
{
"name": "set_lights",
"parameters": {
"type": "object",
"properties": {
"room": {"type": "string"},
"brightness": {"type": "integer"}
},
"required": ["room"]
}
}
],
"answers": [
{
"name": "set_lights",
"arguments": {
"room": "kitchen",
"brightness": 10
}
}
],
"reasoning": "'kitchen' -> room; 'dim to 10' -> brightness 10"
}
1. 数据合成(可选)
需要设置 OPENROUTER_API_KEY。可从工具架构文件生成种子数据,或扩展现有数据集:
export OPENROUTER_API_KEY=sk-or-...
needle generate-data --tools my_tools.json --num-samples 500 --output data.jsonl
needle generate-data --augment data.jsonl --num-samples 500
设置 OPENROUTER_URL 可使用 OpenAI 兼容网关替代默认 OpenRouter 端点。
2. LoRA 微调
若未指定 --checkpoint,基础检查点将自动从 Hugging Face 下载。--generate N 会先从数据中的工具合成 N 个额外示例(同样需要 OPENROUTER_API_KEY)。
needle finetune data.jsonl --epochs 10
needle finetune data.jsonl --epochs 10 --generate 300 --lora-rank 16 --lora-alpha 32
关键选项:--epochs(默认 3)、--lora-rank(16)、--lora-alpha(32)、--lr(1e-4)、--batch-size(16)、--max-len(1024)、--val-split(0.1)、--checkpoint、--checkpoint-dir(默认 checkpoints)、--out、--generate、--model(默认 deepseek/deepseek-v4-flash)、--workers(默认 8)。
--generate 使用配置的 OpenRouter 端点在训练前合成额外示例。适配器默认写入 checkpoints/needle_lora.pkl。每个 epoch 都会从验证集打印验证损失。训练基于纯 JAX 实现,支持所有 JAX 兼容加速器。
在 NVIDIA 机器上安装 CUDA 版本即可在 GPU 上训练:
pip install "cactus-needle[train,gpu]"
在 Apple Silicon 上使用 metal 依赖进行 GPU 训练:
pip install "cactus-needle[train,metal]"
3. 构建微调版 .cact
将适配器合并到基础模型并量化。若基础模型不存在将自动下载。
needle build checkpoints/needle2.pkl --lora checkpoints/needle_lora.pkl --out my_needle.cact
添加 --bits 2 可获得更小模型(默认情况下,导出会沿用检查点声明的逐层位宽映射,若未声明则回退至 4 位)。设置 NEEDLE_HF_REPO=/ 并传入 --upload 可发布 .cact 文件。对应的 needle download //my_needle.cact 命令可在任意机器拉取已发布的归档文件,needle download <platform>(如 macos-arm64)则获取对应平台的引擎运行器。
4. 运行模型
引擎与权重解耦,因此微调后的 .cact 文件可直接运行,无需重新编译:
import needle
agent = needle.Needle(weights="my_needle.cact", tools=[...])
agent.run("...")
遥测数据
Cactus Compute 仅收集完全匿名的使用数据(函数名称、包版本、操作系统),绝不涉及提示内容、输出结果或用户数据。可通过 NEEDLE_TELEMETRY=0 或 DO_NOT_TRACK=1 选择退出。
引用
Needle 2 由 Cactus Compute 团队开发。若在研究中使用,请引用:
@misc{needle2_2026,
title = {Needle 2: A 45M-Parameter Foundation Tool-Calling Model for Tiny Devices},
author = {Ndubuaku, Henry and Mosoyan, Karen and Mroz, Jakub and Cylich, Noah and Kumar, Satyajit and Sandhu, Parkirat and Shemet, Roman and Lee, Justin H.},
year = {2026},
organization = {Cactus Compute, Inc.},
howpublished = {\url{https://github.com/cactus-compute/needle}}
}
如需合作、协同或在产品中部署 Needle 2,请联系 [email protected]。
相似文章
Show HN: Needle2:面向手机、可穿戴设备、智能家居和机器人的14MB智能体LLM
Cactus Compute 发布 Needle 2,这是一个45M参数的智能体LLM,压缩为14MB二进制文件,适用于手机、可穿戴设备、智能家居和机器人,在树莓派5上实现每秒500+ tokens,运行内存仅28MB。
Cactus-Compute/needle
Cactus-Compute 发布了 Needle,这是一个从 Gemini 3.1 蒸馏而来的 2600 万参数模型,采用纯注意力架构,针对设备端推理和本地微调进行了优化。
Needle 2:面向手机、可穿戴设备、智能家居和机器人的 14MB 智能体 LLM。
Cactus 发布了 Needle 2,这是一款面向手机、可穿戴设备、智能家居设备和机器人的 14MB 智能体 LLM,在低端硬件上实现快速推理,并支持结构化提取和微调。
Needle:我们将 Gemini 的函数调用能力蒸馏进了一个 2600 万参数的模型
Cactus-Compute 发布了 Needle,这是一个拥有 2600 万参数的开源模型,从 Gemini 蒸馏而来。它采用一种不含 MLP 的新型“简单注意力网络”架构,旨在实现高效的端侧函数调用。
# 我让一个小型本地模型(llama3.2 3B)稳定地从文档中提取结构化 JSON——难点不在模型本身,而在于围绕它的一切 我一直在做一个项目,需要从各种文档(PDF、邮件、扫描件)中提取结构化数据。我最初以为瓶颈会是模型本身——毕竟 3B 参数不算多。结果发现模型其实还好,真正让我头疼的是周围的工程问题。 以下是我踩过的坑,以及最终解决方案。 --- ## 问题所在 我需要从发票、合同、表单中提取字段,并输出成一致的 JSON 格式,供下游系统使用。需求看起来很简单: ```json { "vendor": "Acme Corp", "invoice_date": "2024-01-15", "total_amount": 1250.00, "line_items": [...] } ``` 但实际情况远比这复杂。 --- ## 坑一:输出格式不一致 这是最明显的问题。即使在 prompt 里明确要求返回 JSON,模型也会: - 在 JSON 前面加上 "Here is the extracted data:" - 混用单引号和双引号 - 在末尾加上解释性文字 - 随机缩进或不缩进 **解决方案:结构化输出 + 严格解析** 与其祈求模型乖乖听话,不如在它输出之后做强制处理。我写了一个提取函数,专门从响应文本中找出 JSON 块: ```python import re import json def extract_json_from_response(text: str) -> dict: # 先尝试直接解析 try: return json.loads(text.strip()) except json.JSONDecodeError: pass # 查找 markdown 代码块 pattern = r'```(?:json)?\s*([\s\S]*?)```' matches = re.findall(pattern, text) if matches: try: return json.loads(matches[0].strip()) except json.JSONDecodeError: pass # 查找第一个完整的 JSON 对象 brace_count = 0 start = None for i, char in enumerate(text): if char == '{': if start is None: start = i brace_count += 1 elif char == '}': brace_count -= 1 if brace_count == 0 and start is not None: try: return json.loads(text[start:i+1]) except json.JSONDecodeError: start = None raise ValueError("响应中未找到有效的 JSON") ``` --- ## 坑二:字段缺失或命名不一致 模型有时会把 `invoice_date` 写成 `date`、`invoice_number` 写成 `number`,或者直接漏掉某些字段。 **解决方案:用 Pydantic 做 schema 验证** ```python from pydantic import BaseModel, validator from typing import Optional, List from datetime import date class LineItem(BaseModel): description: str quantity: float unit_price: float total: float class Invoice(BaseModel): vendor: str invoice_number: Optional[str] = None invoice_date: Optional[date] = None total_amount: float line_items: List[LineItem] = [] @validator('total_amount', pre=True) def parse_amount(cls, v): if isinstance(v, str): # 去掉货币符号和逗号 v = re.sub(r'[,$£€]', '', v) return float(v) ``` Pydantic 帮我做了两件事:一是验证字段是否存在,二是自动做类型转换(比如把字符串金额转成浮点数)。 --- ## 坑三:上下文窗口溢出 3B 的模型上下文窗口有限。一旦文档稍长,提取质量就会急剧下降——模型会开始"幻觉",或者漏掉文档后半部分的内容。 **解决方案:智能分块** 不要把整个文档塞进去,而是先识别文档结构,按逻辑块切分: ```python def chunk_document(text: str, max_tokens: int = 1500) -> list[str]: # 按段落分割 paragraphs = text.split('\n\n') chunks = [] current_chunk = [] current_length = 0 for para in paragraphs: para_length = len(para.split()) if current_length + para_length > max_tokens and current_chunk: chunks.append('\n\n'.join(current_chunk)) current_chunk = [para] current_length = para_length else: current_chunk.append(para) current_length += para_length if current_chunk: chunks.append('\n\n'.join(current_chunk)) return chunks ``` 对于多块文档,我采用"逐块提取 + 合并结果"的策略,以第一块提取的结构为基准,后续块补充缺失字段。 --- ## 坑四:Prompt 工程 这部分花了我最多时间。几个关键发现: **要具体,不要笼统** ❌ 差的 prompt: ``` 从这份文档中提取信息并返回 JSON。 ``` ✅ 好的 prompt: ``` 你是一个发票数据提取专家。从下方发票文本中提取以下字段, 严格以 JSON 格式返回,不要包含任何其他文字: 必填字段: - vendor(字符串):供应商公司名称 - total_amount(数字):发票总金额,不含货币符号 - line_items(数组):每项包含 description、quantity、unit_price、total 可选字段: - invoice_number(字符串) - invoice_date(字符串,格式 YYYY-MM-DD) 如果某个字段在文档中找不到,用 null 表示。 发票文本: {document_text} ``` **给出示例输出** 在 prompt 里加一个 few-shot 示例,输出稳定性会显著提升: ```python EXAMPLE_OUTPUT = """ { "vendor": "示例公司", "invoice_number": "INV-001", "invoice_date": "2024-01-15", "total_amount": 500.00, "line_items": [ { "description": "咨询服务", "quantity": 10, "unit_price": 50.00, "total": 500.00 } ] } """ ``` --- ## 坑五:错误处理和重试 模型偶尔就是会输出垃圾。与其让整个流程崩掉,不如建一个有意义的重试机制: ```python async def extract_with_retry( document: str, schema: type[BaseModel], max_retries: int = 3 ) -> BaseModel: last_error = None for attempt in range(max_retries): try: raw_response = await call_llm(document) json_data = extract_json_from_response(raw_response) return schema(**json_data) except (ValueError, ValidationError) as e: last_error = e if attempt < max_retries - 1: # 把错误信息反馈给模型,让它修正 document = f""" 上一次尝试失败,错误信息:{str(e)} 请修正以下问题并重新提取: {document} """ raise RuntimeError(f"经过 {max_retries} 次尝试后仍然失败:{last_error}") ``` 把错误信息反馈给模型这个技巧很有效——模型通常能根据错误提示自我纠正。 --- ## 最终效果 在一个包含 200 份真实发票的测试集上: | 指标 | 优化前 | 优化后 | |------|--------|--------| | 有效 JSON 率 | 71% | 97% | | 字段完整率 | 58% | 89% | | 端到端成功率 | 43% | 94% | 模型本身从头到尾都是同一个 llama3.2 3B,没有微调,没有换模型。所有提升都来自周围的工程。 --- ## 总结 用小模型做结构化提取,真正的工作量在于: 1. **输出解析**:不要假设模型会输出干净的 JSON 2. **Schema 验证**:用 Pydantic 或类似工具强制约束结构 3. **上下文管理**:主动分块,别等模型自己"决定"忽略什么 4. **Prompt 设计**:具体、有示例、明确格式要求 5. **容错重试**:把错误信息反馈回去,让模型自我修正 模型是拼图的一块,但不是最难的那块。
# 用本地 LLM 构建文档转 JSON 提取器的心得——模型大小没你想的那么重要 几个月前,我着手构建一个完全本地运行的文档信息提取流水线。目标很简单:输入 PDF 或纯文本文档,输出结构化 JSON,整个过程不调用任何外部 API,数据不离开本机。 以下是我踩过的坑、学到的东西,以及目前仍在纠结的问题。 --- ## 技术栈 - **模型**:llama3.2 3B(通过 Ollama 运行) - **语言**:Python - **关键库**:`ollama` Python 客户端、`pydantic` 做数据校验、`pypdf` 解析 PDF - **运行环境**:普通消费级笔记本,无独立 GPU 选择 3B 模型是迫不得已——硬件限制。但这个限制反而逼着我把更多精力放在**系统设计**上,而不是一味依赖模型能力。 --- ## 核心发现:后处理比模型更重要 这是最反直觉的收获。 一开始,我以为只要 prompt 写得足够好,模型就能输出干净的 JSON。现实是:**即使提示词再完美,模型输出也会夹带噪声**——多余的解释性文字、截断的括号、偶尔出现的幻觉字段。 真正让提取质量飞跃的,是在模型输出之后加了一层**确定性后处理**: ```python import re import json def extract_json_from_response(text: str) -> dict: # 先尝试直接解析 try: return json.loads(text.strip()) except json.JSONDecodeError: pass # 用正则抓取第一个 JSON 块 pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' matches = re.findall(pattern, text, re.DOTALL) for match in matches: try: return json.loads(match) except json.JSONDecodeError: continue raise ValueError(f"响应中未找到有效 JSON:{text[:200]}") ``` 这个简单的函数处理了大约 80% 的格式异常情况。 --- ## Schema 约束:另一个关键杠杆 与其让模型"自由发挥"决定输出哪些字段,不如把 schema 直接塞进 prompt,效果会好很多。 我用 Pydantic 定义目标结构,然后动态生成提示词: ```python from pydantic import BaseModel from typing import Optional, List class InvoiceData(BaseModel): invoice_number: str date: str vendor_name: str total_amount: float line_items: List[str] currency: Optional[str] = "CNY" def build_extraction_prompt(document_text: str, schema: BaseModel) -> str: schema_json = schema.schema() return f"""你是一个结构化数据提取助手。请从以下文档中提取信息,并**严格按照**指定的 JSON schema 输出。 ## 目标 Schema ```json {json.dumps(schema_json, ensure_ascii=False, indent=2)} ``` ## 文档内容 {document_text} ## 输出要求 - 只输出 JSON,不要有任何额外说明 - 所有字段必须存在 - 找不到的信息填 null,不要编造 """ ``` 加了这个约束之后,幻觉字段(模型凭空捏造的字段名)减少了大约 60%。 --- ## 仍在头疼的两个问题 ### 1. 长文档的上下文截断 llama3.2 3B 的上下文窗口有限。处理超过 3000 词的文档时,模型会开始"忘记"前面的内容,提取质量明显下降。 我目前的临时方案是滑动窗口分块: ```python def chunk_document(text: str, chunk_size: int = 2000, overlap: int = 200) -> List[str]: words = text.split() chunks = [] start = 0 while start < len(words): end = start + chunk_size chunk = ' '.join(words[start:end]) chunks.append(chunk) start += chunk_size - overlap # 重叠区域保留上下文 return chunks ``` 但分块之后又带来新问题:**如何合并来自不同块的提取结果?** 当一条关键信息横跨两个块的边界时,两边都只拿到了残缺的片段,合并逻辑变得很复杂。 有没有人处理过这类跨块实体合并的问题? ### 2. 幻觉:模型会"脑补"不存在的信息 这是目前最让我头疼的问题。模型有时会对 `null` 值感到"不安",倾向于用听起来合理的内容填充空字段。 我加了一个置信度校验层,但感觉还是治标不治本: ```python def validate_extraction(extracted: dict, source_text: str, threshold: float = 0.8) -> dict: validated = {} for key, value in extracted.items(): if value is None: validated[key] = None continue # 粗略检查:提取的值是否在原文中有据可查 value_str = str(value).lower() source_lower = source_text.lower() if value_str in source_lower: validated[key] = value else: # 标记为存疑,而非直接丢弃 validated[key] = { "value": value, "confidence": "low", "warning": "原文中未找到该值,请人工核查" } return validated ``` 这个方法对数字和日期还算有效,但对于语义层面的幻觉(比如用同义词替换原文表述)就完全失效了。 --- ## 意外惊喜:3B 模型的表现超出预期 坦白说,我预期 3B 模型会很拉垮,结果出乎意料。 在**结构清晰的文档**(发票、表单、标准合同)上,只要后处理做扎实,准确率能到 85–90%。 真正的瓶颈不是模型智力,而是: - 文档格式混乱(扫描件 OCR 质量差) - 字段定义模糊("总金额"到底含不含税?) - 上下文跨块丢失 这让我重新思考一个问题:**在结构化提取任务上,我们是不是高估了大模型的必要性?** 很多时候,一个设计良好的小模型 + 严密的工程约束,能干掉一个被随意调用的大模型。 --- ## 希望听到你们的想法 目前最想求解的几个问题: 1. **跨块合并**:处理长文档分块提取时,你们是怎么做实体对齐和结果合并的? 2. **幻觉检测**:有没有比字符串匹配更可靠的本地化幻觉检测方案? 3. **替代方案**:有没有人试过用 `phi3` 或 `qwen2.5` 做类似任务?在相同硬件上表现如何? 4. **上下文窗口**:Ollama 的 `num_ctx` 参数调大之后,内存占用和推理速度的权衡是怎样的?实际用下来值不值? 这个项目目前是我自用的小工具,但如果能把幻觉问题压下去,我觉得可以做成一个更通用的本地文档处理框架。 欢迎拍砖、分享经验,或者告诉我哪里的思路完全走偏了。